Compare commits

..
19 Commits
Author SHA1 Message Date
EmerikoandClaude Opus 4.8 24b0b98e16 chore: release v4.2.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:28:33 +02:00
DietrichGebert 46c5c28b35 feat: add OpenCode adapter
Thin OpenCode plugin injecting the ponytail ruleset via experimental.chat.system.transform, reusing the shared instruction builder. Verified end-to-end on OpenCode 1.17.4. Supersedes #15.
2026-06-13 03:20:44 +02:00
Abbas Pardawala 1556f10bc6 fix: use CLAUDE_PLUGIN_ROOT in hooks.json, drop duplicate manifest hooks
hooks/hooks.json used ${PLUGIN_ROOT}, which Claude Code never defines, so the literal resolved against the hook process cwd and SessionStart/UserPromptSubmit failed with 'Cannot find module'. Switch to ${CLAUDE_PLUGIN_ROOT} (and %CLAUDE_PLUGIN_ROOT% for the Codex commandWindows variant); Codex aliases CLAUDE_PLUGIN_ROOT so both hosts resolve. Drop the duplicate inline hooks block from .claude-plugin/plugin.json so hooks load from a single canonical source.
2026-06-13 01:53:53 +02:00
Paul Ogier c15db8d3c9 fix: stop mode filter stripping rule bullets with a colon
filterSkillBodyForMode only filters lines whose label is a real mode (lite/full/ultra). Rule bullets like 'No unrequested abstractions:' and the 'ponytail:' comment convention were being stripped from injected instructions in every mode. Adds regression test.
2026-06-13 01:43:26 +02:00
Hannes 8c279cbfb3 feat: add pi extension (#1) 2026-06-12 17:55:24 +02:00
DietrichGebertandClaude Opus 4.8 de318b9457 docs: link agent-portability from README (#10)
The doc landed in #2 but was unlinked. Point to it from the adapters section.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:09:21 +02:00
Chindanai Jaiman e89a4e9863 Add rule copy drift check (#3) 2026-06-12 17:05:09 +02:00
Mark Ingalls 2f2a0d33e0 feat: add Kiro steering file support (#6) 2026-06-12 17:03:55 +02:00
Paul c16f967d37 feat: codex support 2026-06-12 08:49:33 -04:00
dgebertandClaude Fable 5 cf97ccc509 chore: plugin.json version 4.0.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:53:15 +02:00
dgebertandClaude Fable 5 2d91f6a957 docs: move benchmark chart up, right after Before / after
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:45:43 +02:00
dgebertandClaude Fable 5 9c99843725 docs: same-model control arm, refresh numbers and chart
Six no-skill control arms re-run through the same harness so all three
arms share one model. README Numbers section and chart now cite the
complete dataset: -47% tokens, 3x faster, 490 vs 3,629 LOC, extension
96 vs 1,115 lines, probes green everywhere. Em dashes removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:44:30 +02:00
dgebertandClaude Fable 5 b609e019a0 docs: benchmark chart — ponytail vs caveman vs control
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:18:18 +02:00
dgebertandClaude Fable 5 983255e2a1 docs: A-F benchmark — v4 beats caveman on every axis
Probes 8/8 + 6/6 both arms, LOC 490 vs 1440, extension cost 41/55 vs
156/257, all six v4 arms ship a runnable check with no bloat creep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:11:48 +02:00
dgebertandClaude Fable 5 cbb8859f39 feat: skill v4 — test reflex, ceiling comments, robust-variant rule
Hardening per the A-F benchmark brief: non-trivial logic leaves one
runnable check; ponytail: comments on ceiling-bearing shortcuts name
the ceiling and upgrade path; prefer the edge-case-correct stdlib
option at equal size. Applied to SKILL.md, all cross-agent rule
copies, the hook fallback, and a guard line in ponytail-review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:11:47 +02:00
34 changed files with 1273 additions and 153 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ponytail",
"interface": {
"displayName": "Ponytail"
},
"plugins": [
{
"name": "ponytail",
"source": {
"source": "url",
"url": "https://github.com/DietrichGebert/ponytail.git",
"ref": "main"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+2 -27
View File
@@ -1,34 +1,9 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works — YAGNI, stdlib first, no unrequested abstractions.",
"version": "4.2.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"
},
"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..."
}
]
}
]
}
}
+3 -2
View File
@@ -18,6 +18,7 @@ Rules:
- 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?"
- 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.
+31
View File
@@ -0,0 +1,31 @@
{
"name": "ponytail",
"version": "4.2.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"
}
}
+3 -2
View File
@@ -24,6 +24,7 @@ Rules:
- 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?"
- 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.
+3 -2
View File
@@ -18,6 +18,7 @@ Rules:
- 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?"
- 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.
+29
View File
@@ -0,0 +1,29 @@
---
title: Ponytail — lazy senior dev mode
inclusion: always
---
# Ponytail — lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does the standard library already do this? Use it.
3. Does a native platform feature cover it? Use it.
4. Does an already-installed dependency solve it? Use it.
5. Can this be one line? Make it one line.
6. Only then: write the minimum code that works.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size — lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, anything explicitly requested. Non-trivial logic leaves ONE runnable check behind — the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
+5
View File
@@ -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.'
+5
View File
@@ -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.
+65
View File
@@ -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);
},
};
};
+3 -2
View File
@@ -18,6 +18,7 @@ Rules:
- 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?"
- 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.
+3 -2
View File
@@ -18,8 +18,9 @@ Rules:
- 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?"
- 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.)
+56 -7
View File
@@ -1,5 +1,5 @@
<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>
<h1 align="center">Ponytail</h1>
@@ -27,6 +27,16 @@ With ponytail:
More survivors in [examples/](examples/).
## Numbers
Six tasks: streaming log parser, atomic file sync, notification dispatcher, validation engine, auth module, concurrent money ledger. One spec each, one fresh agent per arm, same model. Three arms: no skill, the [caveman](https://github.com/JuliusBrussee/caveman) skill, and ponytail. Every arm passes the same adversarial security and concurrency probes. Then the agreement ends:
<p align="center">
<img src="assets/benchmark-loc.svg" width="860" alt="Lines of code per task: ponytail 490 total vs caveman 1,440 vs no-skill control 3,629, all passing the same adversarial probes">
</p>
**47% fewer tokens than the no-skill agent. 3× faster. A seventh of the code.** The 3,139 lines nobody wrote have never caused an incident. When a surprise feature request hit two of the tasks, ponytail extended in 96 changed lines; caveman needed 413, the no-skill agent 1,115. Every shortcut ponytail took is marked in the code with a `ponytail:` comment naming its upgrade path. Data: [benchmarks/](benchmarks/).
## How it works
Before writing code, the agent stops at the first rung that holds:
@@ -46,16 +56,59 @@ Lazy, not negligent: trust-boundary validation, data-loss handling, security, an
The most effort ponytail will ever ask of you:
### Claude Code
```
/plugin marketplace add DietrichGebert/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` and `/ponytail-review`. 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.
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.
Cursor, Windsurf, Cline, Copilot, Aider: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md)).
In Codex, invoke the skills as `@ponytail`, `@ponytail-review`, and
`@ponytail-help`. Startup and mode-change text shows the current mode.
Cursor, Windsurf, Cline, Copilot, Aider, Kiro: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/)).
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
Which files map to which agent: [Agent portability](docs/agent-portability.md).
## Development
When changing the compact rule text, keep the agent copies aligned:
```bash
node scripts/check-rule-copies.js
```
## FAQ
@@ -63,7 +116,7 @@ Cursor, Windsurf, Cline, Copilot, Aider: copy the matching rules file from this
No.
**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?**
The code you never wrote scales infinitely. Zero bugs, zero CVEs, 100% uptime since forever.
@@ -71,10 +124,6 @@ The code you never wrote scales infinitely. Zero bugs, zero CVEs, 100% uptime si
**Why "ponytail"?**
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
[MIT](LICENSE). The shortest license that works.
+69
View File
@@ -0,0 +1,69 @@
<svg viewBox="0 0 860 470" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif">
<title>Non-blank lines of code per task: control vs caveman vs ponytail</title>
<text x="20" y="26" font-size="15" font-weight="600" fill="#8b949e">Six tasks. Adversarial probes: everyone passes. Lines of code: not everyone.</text>
<!-- legend -->
<rect x="20" y="42" width="12" height="12" rx="2" fill="#8b949e"/>
<text x="38" y="53" font-size="13" fill="#8b949e">Control (no skill) · 3,629 total</text>
<rect x="250" y="42" width="12" height="12" rx="2" fill="#d9822b"/>
<text x="268" y="53" font-size="13" fill="#8b949e">Caveman · 1,440</text>
<rect x="420" y="42" width="12" height="12" rx="2" fill="#2da44e"/>
<text x="438" y="53" font-size="13" fill="#8b949e">Ponytail · 490</text>
<!-- Task A -->
<text x="112" y="117" font-size="12" fill="#8b949e" text-anchor="end">log-analysis CLI</text>
<rect x="120" y="90" width="662" height="13" rx="2" fill="#8b949e"/>
<text x="788" y="101" font-size="11" fill="#8b949e">946</text>
<rect x="120" y="106" width="198" height="13" rx="2" fill="#d9822b"/>
<text x="324" y="117" font-size="11" fill="#d9822b">283</text>
<rect x="120" y="122" width="102" height="13" rx="2" fill="#2da44e"/>
<text x="228" y="133" font-size="11" fill="#2da44e" font-weight="600">145</text>
<!-- Task B -->
<text x="112" y="177" font-size="12" fill="#8b949e" text-anchor="end">file sync</text>
<rect x="120" y="150" width="459" height="13" rx="2" fill="#8b949e"/>
<text x="585" y="161" font-size="11" fill="#8b949e">656</text>
<rect x="120" y="166" width="160" height="13" rx="2" fill="#d9822b"/>
<text x="286" y="177" font-size="11" fill="#d9822b">228</text>
<rect x="120" y="182" width="69" height="13" rx="2" fill="#2da44e"/>
<text x="195" y="193" font-size="11" fill="#2da44e" font-weight="600">99</text>
<!-- Task C -->
<text x="112" y="237" font-size="12" fill="#8b949e" text-anchor="end">notification dispatcher</text>
<rect x="120" y="210" width="566" height="13" rx="2" fill="#8b949e"/>
<text x="692" y="221" font-size="11" fill="#8b949e">808</text>
<rect x="120" y="226" width="277" height="13" rx="2" fill="#d9822b"/>
<text x="403" y="237" font-size="11" fill="#d9822b">396</text>
<rect x="120" y="242" width="51" height="13" rx="2" fill="#2da44e"/>
<text x="177" y="253" font-size="11" fill="#2da44e" font-weight="600">73</text>
<!-- Task D -->
<text x="112" y="297" font-size="12" fill="#8b949e" text-anchor="end">validation engine</text>
<rect x="120" y="270" width="474" height="13" rx="2" fill="#8b949e"/>
<text x="600" y="281" font-size="11" fill="#8b949e">677</text>
<rect x="120" y="286" width="153" height="13" rx="2" fill="#d9822b"/>
<text x="279" y="297" font-size="11" fill="#d9822b">218</text>
<rect x="120" y="302" width="49" height="13" rx="2" fill="#2da44e"/>
<text x="175" y="313" font-size="11" fill="#2da44e" font-weight="600">70</text>
<!-- Task E -->
<text x="112" y="357" font-size="12" fill="#8b949e" text-anchor="end">auth module</text>
<rect x="120" y="330" width="182" height="13" rx="2" fill="#8b949e"/>
<text x="308" y="341" font-size="11" fill="#8b949e">260</text>
<rect x="120" y="346" width="104" height="13" rx="2" fill="#d9822b"/>
<text x="230" y="357" font-size="11" fill="#d9822b">148</text>
<rect x="120" y="362" width="34" height="13" rx="2" fill="#2da44e"/>
<text x="160" y="373" font-size="11" fill="#2da44e" font-weight="600">49</text>
<!-- Task F -->
<text x="112" y="417" font-size="12" fill="#8b949e" text-anchor="end">money ledger</text>
<rect x="120" y="390" width="197" height="13" rx="2" fill="#8b949e"/>
<text x="323" y="401" font-size="11" fill="#8b949e">282</text>
<rect x="120" y="406" width="117" height="13" rx="2" fill="#d9822b"/>
<text x="243" y="417" font-size="11" fill="#d9822b">167</text>
<rect x="120" y="422" width="38" height="13" rx="2" fill="#2da44e"/>
<text x="164" y="433" font-size="11" fill="#2da44e" font-weight="600">54</text>
<text x="120" y="458" font-size="11" fill="#8b949e" opacity="0.8">Non-blank LOC, AST-counted. Same model, same specs, one fresh agent per arm. Every arm passes the same security (8/8) and concurrency (6/6) probes. 2026-06-12.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,125 @@
# Ponytail v4 hardening — AF 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 7479% 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`;
BF: 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 2543% *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.
+32
View File
@@ -0,0 +1,32 @@
# 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. |
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
| Cline | `.clinerules/ponytail.md` | Project rule. |
| GitHub Copilot | `.github/copilot-instructions.md` | Repository instruction file. |
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |
## Adapter Rule
Keep adapters thin. When a host supports skills or hooks, point it at the
existing `skills/` and `hooks/` files. When a host only supports project
instructions, keep its copied rule text aligned with `AGENTS.md`.
## Portable Behavior
- `skills/ponytail/SKILL.md`: lazy senior dev mode
- `skills/ponytail-review/SKILL.md`: over-engineering review
- `skills/ponytail-help/SKILL.md`: quick reference
- `AGENTS.md`: compact always-on instruction set for agents without skill support
+31
View File
@@ -0,0 +1,31 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"commandWindows": "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\"",
"commandWindows": "node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\ponytail-mode-tracker.js\"",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."
}
]
}
]
}
}
+13 -94
View File
@@ -10,119 +10,38 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
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 flagPath = path.join(claudeDir, '.ponytail-active');
const settingsPath = path.join(claudeDir, 'settings.json');
const mode = getDefaultMode();
// "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {}
process.stdout.write('OK');
clearMode();
writeHookOutput('SessionStart', 'off', isCodex ? '' : 'OK');
process.exit(0);
}
// 1. Write flag file
try {
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
fs.writeFileSync(flagPath, mode);
setMode(mode);
} catch (e) {
// Silent fail -- flag is best-effort, don't block the hook
}
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
// A short summary is too weak — models drift back to over-building
// 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.';
}
let output = getPonytailInstructions(mode);
// 3. Detect missing statusline config — nudge Claude to help set it up
try {
if (!isCodex) try {
let hasStatusline = false;
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
@@ -151,4 +70,4 @@ try {
// Silent fail — don't block session start over statusline detection
}
process.stdout.write(output);
writeHookOutput('SessionStart', mode, output);
+41 -2
View File
@@ -13,7 +13,25 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const DEFAULT_MODE = 'full';
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() {
if (process.env.XDG_CONFIG_HOME) {
@@ -51,7 +69,28 @@ function getDefaultMode() {
}
// 3. Default
return 'full';
return DEFAULT_MODE;
}
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES };
function writeDefaultMode(mode) {
const normalized = normalizeConfigMode(mode);
if (!normalized) return null;
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8');
return normalized;
}
module.exports = {
DEFAULT_MODE,
VALID_MODES,
RUNTIME_MODES,
getDefaultMode,
getConfigDir,
getConfigPath,
normalizeMode,
normalizeConfigMode,
normalizePersistedMode,
writeDefaultMode,
};
+91
View File
@@ -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,
};
+13 -11
View File
@@ -2,12 +2,8 @@
// ponytail — UserPromptSubmit hook to track which ponytail mode is active
// 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 flagPath = path.join(os.homedir(), '.claude', '.ponytail-active');
const { clearMode, setMode, writeHookOutput } = require('./ponytail-runtime');
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
@@ -18,9 +14,9 @@ process.stdin.on('end', () => {
const prompt = (data.prompt || '').trim().toLowerCase();
// Match /ponytail commands
if (prompt.startsWith('/ponytail')) {
if (/^[/@$]ponytail/.test(prompt)) {
const parts = prompt.split(/\s+/);
const cmd = parts[0]; // /ponytail, /ponytail-review, /ponytail:ponytail, etc.
const cmd = parts[0].replace(/^[@$]/, '/');
const arg = parts[1] || '';
let mode = null;
@@ -36,16 +32,22 @@ process.stdin.on('end', () => {
}
if (mode && mode !== 'off') {
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
fs.writeFileSync(flagPath, mode);
setMode(mode);
writeHookOutput(
'UserPromptSubmit',
mode,
'PONYTAIL MODE CHANGED — level: ' + mode,
);
} else if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {}
clearMode();
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
}
}
// Detect deactivation
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) {
// Silent fail
+39
View File
@@ -0,0 +1,39 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const isCodex = Boolean(process.env.PLUGIN_DATA);
const statePath = isCodex
? path.join(process.env.PLUGIN_DATA, '.ponytail-active')
: path.join(os.homedir(), '.claude', '.ponytail-active');
function setMode(mode) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, mode);
}
function clearMode() {
try { fs.unlinkSync(statePath); } catch (e) {}
}
function writeHookOutput(event, mode, context = '') {
if (!isCodex) {
process.stdout.write(context);
return;
}
const output = { systemMessage: `PONYTAIL:${mode.toUpperCase()}` };
if (context) {
output.hookSpecificOutput = {
hookEventName: event,
additionalContext: context,
};
}
process.stdout.write(JSON.stringify(output));
}
module.exports = {
clearMode,
isCodex,
setMode,
writeHookOutput,
};
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["./.opencode/plugins/ponytail.mjs"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "ponytail",
"version": "0.1.0",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"keywords": ["pi-package", "pi", "skills", "ponytail"],
"license": "MIT",
"pi": {
"extensions": ["./pi-extension/index.js"],
"skills": ["./skills"]
}
}
+141
View File
@@ -0,0 +1,141 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
DEFAULT_MODE,
getDefaultMode,
normalizeMode,
normalizeConfigMode,
normalizePersistedMode,
writeDefaultMode,
} = require("../hooks/ponytail-config.js");
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
export { filterSkillBodyForMode };
export const readDefaultMode = getDefaultMode;
export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
if (!Array.isArray(entries)) return fallback;
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;
const mode = normalizePersistedMode(entry?.data?.mode);
if (mode) return mode;
}
return fallback;
}
export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) {
const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
const normalizedText = String(text || "").trim().toLowerCase();
if (!normalizedText) {
return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
}
const [primary, secondary] = normalizedText.split(/\s+/);
if (primary === "status") return { type: "status" };
if (primary === "default") {
const mode = normalizeConfigMode(secondary);
return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
}
const mode = normalizeMode(primary);
return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
}
export { writeDefaultMode };
export default function ponytailExtension(pi) {
let currentMode = DEFAULT_MODE;
let configuredDefaultMode = getDefaultMode();
const setMode = (mode, ctx) => {
const normalized = normalizePersistedMode(mode);
if (!normalized) return;
currentMode = normalized;
pi.appendEntry("ponytail-mode", { mode: normalized });
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
};
const sendAlias = (skillName, args, ctx) => {
const normalized = String(args || "").trim();
const message = normalized ? `${skillName} ${normalized}` : skillName;
if (ctx?.isIdle?.() === false) {
pi.sendUserMessage(message, { deliverAs: "followUp" });
ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
return;
}
pi.sendUserMessage(message);
};
pi.registerCommand("ponytail", {
description: "Set or report Ponytail mode",
handler: async (args, ctx) => {
const parsed = parsePonytailCommand(args, configuredDefaultMode);
if (parsed.type === "status") {
ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
return;
}
if (parsed.type === "set-default") {
const written = writeDefaultMode(parsed.mode);
if (written) {
configuredDefaultMode = getDefaultMode();
const message = configuredDefaultMode === written
? `Default Ponytail mode set to ${written}.`
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
ctx?.ui?.notify?.(message, "info");
}
return;
}
if (parsed.type === "set-mode") {
setMode(parsed.mode, ctx);
return;
}
ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
},
});
pi.registerCommand("ponytail-review", {
description: "Run /skill:ponytail-review",
handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx),
});
pi.registerCommand("ponytail-help", {
description: "Run /skill:ponytail-help",
handler: (_args, ctx) => sendAlias("/skill:ponytail-help", "", ctx),
});
pi.on("input", async (event) => {
if (event?.source === "extension") return;
const text = String(event?.text || "");
if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(text)) {
setMode("off");
}
});
pi.on("session_start", async (_event, ctx) => {
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
configuredDefaultMode = getDefaultMode();
currentMode = resolveSessionMode(entries, configuredDefaultMode);
});
pi.on("before_agent_start", async (event) => {
if (!currentMode || currentMode === "off") return;
return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` };
});
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "ponytail-pi-extension-dev",
"private": true,
"type": "module",
"scripts": {
"test": "node --test ./test/*.test.js"
}
}
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import ponytailExtension from "../index.js";
function createPiHarness() {
const events = new Map();
const commands = new Map();
const appendedEntries = [];
const sentUserMessages = [];
const pi = {
on(eventName, handler) {
events.set(eventName, handler);
},
registerCommand(name, options) {
commands.set(name, options);
},
appendEntry(customType, data) {
appendedEntries.push({ customType, data });
},
sendUserMessage(text, options) {
sentUserMessages.push({ text, options });
},
};
ponytailExtension(pi);
return { events, commands, appendedEntries, sentUserMessages };
}
function createCommandContext(overrides = {}) {
return {
isIdle: () => true,
sessionManager: { getEntries: () => [] },
ui: { notify() {} },
...overrides,
};
}
function withTempConfig(fn) {
const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
const previousXdg = process.env.XDG_CONFIG_HOME;
process.env.XDG_CONFIG_HOME = tempConfigHome;
return Promise.resolve()
.then(fn)
.finally(() => {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
rmSync(tempConfigHome, { recursive: true, force: true });
});
}
test("extension registers Ponytail commands", () => {
const { commands } = createPiHarness();
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-help", "ponytail-review"]);
});
test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
const { commands, events, appendedEntries } = createPiHarness();
const ctx = createCommandContext();
await events.get("session_start")({ reason: "startup" }, ctx);
await commands.get("ponytail").handler("ultra", ctx);
assert.deepEqual(appendedEntries.at(-1), {
customType: "ponytail-mode",
data: { mode: "ultra" },
});
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.ok(result.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
assert.ok(result.systemPrompt.includes("ultra"));
}));
test("session_start restores latest persisted mode", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const ctx = createCommandContext({
sessionManager: {
getEntries: () => [
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
],
},
});
await events.get("session_start")({ reason: "resume" }, ctx);
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.ok(result.systemPrompt.includes("lite"));
}));
test("skill alias commands delegate to Pi skill commands", async () => {
const { commands, sentUserMessages } = createPiHarness();
const ctx = createCommandContext();
await commands.get("ponytail-review").handler("", ctx);
await commands.get("ponytail-help").handler("", ctx);
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
"/skill:ponytail-review",
"/skill:ponytail-help",
]);
});
test("normal mode disables persistent instructions", async () => withTempConfig(async () => {
const { commands, events } = createPiHarness();
const ctx = createCommandContext();
await events.get("session_start")({ reason: "startup" }, ctx);
await commands.get("ponytail").handler("ultra", ctx);
await events.get("input")({ text: "normal mode", source: "interactive" }, ctx);
const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.equal(disabled, undefined);
}));
+85
View File
@@ -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'));
});
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
function read(relPath) {
return fs.readFileSync(path.join(root, relPath), 'utf8').replace(/\r\n/g, '\n').trim();
}
function stripFrontmatter(text) {
return text.replace(/^---\n[\s\S]*?\n---\n*/, '').trim();
}
const agents = read('AGENTS.md');
const canonical = agents.replace(/\n\n\(Yes, this file also applies[\s\S]*?\)$/, '').trim();
// Compact copies: same body as AGENTS.md, host-specific frontmatter stripped.
const copies = [
['.cursor/rules/ponytail.mdc', stripFrontmatter],
['.windsurf/rules/ponytail.md', text => text.trim()],
['.clinerules/ponytail.md', text => text.trim()],
['.github/copilot-instructions.md', text => text.trim()],
['.kiro/steering/ponytail.md', stripFrontmatter],
];
let failed = false;
for (const [relPath, normalize] of copies) {
const actual = normalize(read(relPath));
if (actual !== canonical) {
console.error(`${relPath} drifted from AGENTS.md`);
failed = true;
}
}
// SKILL.md is the runtime source of truth and is longer than the compact body,
// so it cannot be byte-compared. ponytail: canary, not full equality. Assert the
// load-bearing rules survive verbatim in both the source and AGENTS.md. Changing
// a rule's wording trips this, which is the reminder to propagate it everywhere.
// Upgrade path: generate the copies from SKILL.md if this ever misses a real drift.
const INVARIANTS = [
'naive heuristic', // ceiling-comment rule
'ONE runnable check', // test reflex
'flimsier algorithm', // robust-variant rule
'input validation at trust boundaries', // the "not lazy about" clause
];
const skill = read('skills/ponytail/SKILL.md');
const sources = [['skills/ponytail/SKILL.md', skill], ['AGENTS.md', agents]];
for (const phrase of INVARIANTS) {
for (const [label, text] of sources) {
if (!text.includes(phrase)) {
console.error(`${label} is missing rule invariant: "${phrase}"`);
failed = true;
}
}
}
if (failed) {
console.error('Update the copied rule text, AGENTS.md, or SKILL.md so the shared rules match.');
process.exit(1);
}
console.log(`Rule copies match AGENTS.md; ${INVARIANTS.length} rule invariants present in SKILL.md and AGENTS.md.`);
+4
View File
@@ -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-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
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
+3 -1
View File
@@ -50,5 +50,7 @@ If there is nothing to cut, say `Lean already. Ship.` and stop.
## Boundaries
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.
+8 -1
View File
@@ -46,7 +46,8 @@ higher one and move on. The first lazy solution that works is the right one.
- Deletion over addition. Boring over clever — clever is what someone decodes at 3am.
- 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.
- 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
@@ -77,6 +78,12 @@ that prevents data loss, security measures, accessibility basics, anything
explicitly requested. User insists on the full version → build it, no
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
Ponytail governs what you build, not how you talk (pair with Caveman for
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const root = path.join(__dirname, '..');
function run(script, env, input = '') {
return spawnSync(process.execPath, [path.join(root, 'hooks', script)], {
env: { ...process.env, ...env },
input,
encoding: 'utf8',
});
}
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-'));
const home = path.join(temp, 'home');
const pluginData = path.join(temp, 'plugin-data');
fs.mkdirSync(home, { recursive: true });
// USERPROFILE alongside HOME: os.homedir() reads USERPROFILE on Windows, HOME on POSIX.
const codexEnv = {
HOME: home,
USERPROFILE: home,
PLUGIN_DATA: pluginData,
PONYTAIL_DEFAULT_MODE: 'ultra',
};
const codexState = path.join(pluginData, '.ponytail-active');
let result = run('ponytail-activate.js', codexEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'ultra');
let output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:ULTRA');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: ultra/,
);
result = run(
'ponytail-mode-tracker.js',
codexEnv,
JSON.stringify({ prompt: '@ponytail lite' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite');
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:LITE');
result = run(
'ponytail-mode-tracker.js',
codexEnv,
JSON.stringify({ prompt: 'normal mode' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(codexState), false);
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:OFF');
const claudeEnv = {
HOME: home,
USERPROFILE: home,
PONYTAIL_DEFAULT_MODE: 'full',
};
delete claudeEnv.PLUGIN_DATA;
result = run('ponytail-activate.js', claudeEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.readFileSync(path.join(home, '.claude', '.ponytail-active'), 'utf8'),
'full',
);
fs.rmSync(temp, { recursive: true, force: true });
console.log('hook compatibility checks passed');
+64
View File
@@ -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 }));