Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.2.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..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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: 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -27,6 +27,16 @@ With ponytail:
|
|||||||
|
|
||||||
More survivors in [examples/](examples/).
|
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
|
## 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 +56,59 @@ 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` 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.
|
That was it. He'd be proud. He won't say it.
|
||||||
|
|
||||||
Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest.
|
Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest.
|
||||||
|
|
||||||
Cursor, Windsurf, Cline, Copilot, Aider: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md)).
|
In Codex, invoke the skills as `@ponytail`, `@ponytail-review`, and
|
||||||
|
`@ponytail-help`. Startup and mode-change text shows the current mode.
|
||||||
|
|
||||||
|
Cursor, Windsurf, Cline, Copilot, Aider, Kiro: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/)).
|
||||||
|
|
||||||
|
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
|
||||||
|
|
||||||
|
Which files map to which agent: [Agent portability](docs/agent-portability.md).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
When changing the compact rule text, keep the agent copies aligned:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/check-rule-copies.js
|
||||||
|
```
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
@@ -63,7 +116,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 +124,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,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 — 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,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
|
||||||
@@ -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
@@ -10,119 +10,38 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { getDefaultMode } = require('./ponytail-config');
|
const { getDefaultMode } = require('./ponytail-config');
|
||||||
|
const { getPonytailInstructions } = require('./ponytail-instructions');
|
||||||
|
const {
|
||||||
|
clearMode,
|
||||||
|
isCodex,
|
||||||
|
setMode,
|
||||||
|
writeHookOutput,
|
||||||
|
} = require('./ponytail-runtime');
|
||||||
|
|
||||||
const claudeDir = path.join(os.homedir(), '.claude');
|
const claudeDir = path.join(os.homedir(), '.claude');
|
||||||
const flagPath = path.join(claudeDir, '.ponytail-active');
|
|
||||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||||
|
|
||||||
const mode = getDefaultMode();
|
const mode = getDefaultMode();
|
||||||
|
|
||||||
// "off" mode — skip activation entirely, don't write flag or emit rules
|
// "off" mode — skip activation entirely, don't write flag or emit rules
|
||||||
if (mode === 'off') {
|
if (mode === 'off') {
|
||||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
clearMode();
|
||||||
process.stdout.write('OK');
|
writeHookOutput('SessionStart', 'off', isCodex ? '' : 'OK');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Write flag file
|
// 1. Write flag file
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
setMode(mode);
|
||||||
fs.writeFileSync(flagPath, mode);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent fail -- flag is best-effort, don't block the hook
|
// Silent fail -- flag is best-effort, don't block the hook
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
|
// 2. Emit the ponytail ruleset, filtered to the active intensity level.
|
||||||
// A short summary is too weak — models drift back to over-building
|
let output = getPonytailInstructions(mode);
|
||||||
// mid-conversation, especially after context compression prunes it.
|
|
||||||
// Full rules with examples anchor behavior much more reliably.
|
|
||||||
//
|
|
||||||
// Reads SKILL.md at runtime so edits to the source of truth propagate
|
|
||||||
// automatically — no hardcoded duplication to go stale.
|
|
||||||
|
|
||||||
// Modes that have their own independent skill files — not intensity levels.
|
|
||||||
// For these, emit a short activation line; the skill itself handles behavior.
|
|
||||||
const INDEPENDENT_MODES = new Set(['review']);
|
|
||||||
|
|
||||||
if (INDEPENDENT_MODES.has(mode)) {
|
|
||||||
process.stdout.write('PONYTAIL MODE ACTIVE — level: ' + mode + '. Behavior defined by /ponytail-' + mode + ' skill.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read SKILL.md — the single source of truth for ponytail behavior.
|
|
||||||
// Plugin installs: __dirname = <plugin_root>/hooks/, SKILL.md at <plugin_root>/skills/ponytail/SKILL.md
|
|
||||||
let skillContent = '';
|
|
||||||
try {
|
|
||||||
skillContent = fs.readFileSync(
|
|
||||||
path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8'
|
|
||||||
);
|
|
||||||
} catch (e) { /* standalone install — will use fallback below */ }
|
|
||||||
|
|
||||||
let output;
|
|
||||||
|
|
||||||
if (skillContent) {
|
|
||||||
// Strip YAML frontmatter
|
|
||||||
const body = skillContent.replace(/^---[\s\S]*?---\s*/, '');
|
|
||||||
|
|
||||||
// Filter intensity table: keep header rows + only the active level's row
|
|
||||||
const filtered = body.split('\n').reduce((acc, line) => {
|
|
||||||
// Intensity table rows start with | **level** |
|
|
||||||
const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
|
|
||||||
if (tableRowMatch) {
|
|
||||||
if (tableRowMatch[1] === mode) {
|
|
||||||
acc.push(line);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example lines start with "- level:" — keep only lines matching active level
|
|
||||||
const exampleMatch = line.match(/^- (\S+?):\s/);
|
|
||||||
if (exampleMatch) {
|
|
||||||
if (exampleMatch[1] === mode) {
|
|
||||||
acc.push(line);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.push(line);
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
output = 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' + filtered.join('\n');
|
|
||||||
} else {
|
|
||||||
// Fallback when SKILL.md is not found (hook installed without skills dir).
|
|
||||||
// Minimum viable ruleset — better than nothing.
|
|
||||||
output =
|
|
||||||
'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' +
|
|
||||||
'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
|
|
||||||
'## Persistence\n\n' +
|
|
||||||
'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
|
|
||||||
'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' +
|
|
||||||
'## The ladder\n\n' +
|
|
||||||
'Before any code, stop at the first rung that holds:\n' +
|
|
||||||
'1. Does this need to be built at all? (YAGNI)\n' +
|
|
||||||
'2. Does the standard library do this? Use it.\n' +
|
|
||||||
'3. Does a native platform feature cover it? Use it.\n' +
|
|
||||||
'4. Does an already-installed dependency solve it? Use it.\n' +
|
|
||||||
'5. Can this be one line? Make it one line.\n' +
|
|
||||||
'6. Only then: write the minimum code that works.\n\n' +
|
|
||||||
'## Rules\n\n' +
|
|
||||||
'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' +
|
|
||||||
'Deletion over addition. Boring over clever. Fewest files possible. ' +
|
|
||||||
'Ship the lazy version and question the complex request in the same response — never stall. ' +
|
|
||||||
'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 +70,4 @@ try {
|
|||||||
// Silent fail — don't block session start over statusline detection
|
// Silent fail — don't block session start over statusline detection
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdout.write(output);
|
writeHookOutput('SessionStart', mode, output);
|
||||||
|
|||||||
@@ -13,7 +13,25 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
|
const DEFAULT_MODE = 'full';
|
||||||
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
|
||||||
|
const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
|
||||||
|
|
||||||
|
function normalizeMode(mode) {
|
||||||
|
if (typeof mode !== 'string') return null;
|
||||||
|
const normalized = mode.trim().toLowerCase();
|
||||||
|
return RUNTIME_MODES.includes(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfigMode(mode) {
|
||||||
|
if (typeof mode !== 'string') return null;
|
||||||
|
const normalized = mode.trim().toLowerCase();
|
||||||
|
return VALID_MODES.includes(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersistedMode(mode) {
|
||||||
|
return normalizeMode(mode) || normalizeConfigMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
function getConfigDir() {
|
function getConfigDir() {
|
||||||
if (process.env.XDG_CONFIG_HOME) {
|
if (process.env.XDG_CONFIG_HOME) {
|
||||||
@@ -51,7 +69,28 @@ function getDefaultMode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Default
|
// 3. Default
|
||||||
return 'full';
|
return DEFAULT_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES };
|
function writeDefaultMode(mode) {
|
||||||
|
const normalized = normalizeConfigMode(mode);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MODE,
|
||||||
|
VALID_MODES,
|
||||||
|
RUNTIME_MODES,
|
||||||
|
getDefaultMode,
|
||||||
|
getConfigDir,
|
||||||
|
getConfigPath,
|
||||||
|
normalizeMode,
|
||||||
|
normalizeConfigMode,
|
||||||
|
normalizePersistedMode,
|
||||||
|
writeDefaultMode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,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 os = require('os');
|
||||||
|
|
||||||
|
const isCodex = Boolean(process.env.PLUGIN_DATA);
|
||||||
|
const statePath = isCodex
|
||||||
|
? path.join(process.env.PLUGIN_DATA, '.ponytail-active')
|
||||||
|
: path.join(os.homedir(), '.claude', '.ponytail-active');
|
||||||
|
|
||||||
|
function setMode(mode) {
|
||||||
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||||
|
fs.writeFileSync(statePath, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMode() {
|
||||||
|
try { fs.unlinkSync(statePath); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeHookOutput(event, mode, context = '') {
|
||||||
|
if (!isCodex) {
|
||||||
|
process.stdout.write(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const output = { systemMessage: `PONYTAIL:${mode.toUpperCase()}` };
|
||||||
|
if (context) {
|
||||||
|
output.hookSpecificOutput = {
|
||||||
|
hookEventName: event,
|
||||||
|
additionalContext: context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clearMode,
|
||||||
|
isCodex,
|
||||||
|
setMode,
|
||||||
|
writeHookOutput,
|
||||||
|
};
|
||||||
@@ -0,0 +1,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,141 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const {
|
||||||
|
DEFAULT_MODE,
|
||||||
|
getDefaultMode,
|
||||||
|
normalizeMode,
|
||||||
|
normalizeConfigMode,
|
||||||
|
normalizePersistedMode,
|
||||||
|
writeDefaultMode,
|
||||||
|
} = require("../hooks/ponytail-config.js");
|
||||||
|
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
|
||||||
|
|
||||||
|
export { filterSkillBodyForMode };
|
||||||
|
export const readDefaultMode = getDefaultMode;
|
||||||
|
|
||||||
|
export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
|
||||||
|
const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
|
||||||
|
if (!Array.isArray(entries)) return fallback;
|
||||||
|
|
||||||
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;
|
||||||
|
|
||||||
|
const mode = normalizePersistedMode(entry?.data?.mode);
|
||||||
|
if (mode) return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) {
|
||||||
|
const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
|
||||||
|
const normalizedText = String(text || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalizedText) {
|
||||||
|
return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [primary, secondary] = normalizedText.split(/\s+/);
|
||||||
|
|
||||||
|
if (primary === "status") return { type: "status" };
|
||||||
|
|
||||||
|
if (primary === "default") {
|
||||||
|
const mode = normalizeConfigMode(secondary);
|
||||||
|
return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = normalizeMode(primary);
|
||||||
|
return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { writeDefaultMode };
|
||||||
|
|
||||||
|
export default function ponytailExtension(pi) {
|
||||||
|
let currentMode = DEFAULT_MODE;
|
||||||
|
let configuredDefaultMode = getDefaultMode();
|
||||||
|
|
||||||
|
const setMode = (mode, ctx) => {
|
||||||
|
const normalized = normalizePersistedMode(mode);
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
currentMode = normalized;
|
||||||
|
pi.appendEntry("ponytail-mode", { mode: normalized });
|
||||||
|
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendAlias = (skillName, args, ctx) => {
|
||||||
|
const normalized = String(args || "").trim();
|
||||||
|
const message = normalized ? `${skillName} ${normalized}` : skillName;
|
||||||
|
|
||||||
|
if (ctx?.isIdle?.() === false) {
|
||||||
|
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
||||||
|
ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.sendUserMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail", {
|
||||||
|
description: "Set or report Ponytail mode",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const parsed = parsePonytailCommand(args, configuredDefaultMode);
|
||||||
|
|
||||||
|
if (parsed.type === "status") {
|
||||||
|
ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "set-default") {
|
||||||
|
const written = writeDefaultMode(parsed.mode);
|
||||||
|
if (written) {
|
||||||
|
configuredDefaultMode = getDefaultMode();
|
||||||
|
const message = configuredDefaultMode === written
|
||||||
|
? `Default Ponytail mode set to ${written}.`
|
||||||
|
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
|
||||||
|
ctx?.ui?.notify?.(message, "info");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "set-mode") {
|
||||||
|
setMode(parsed.mode, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail-review", {
|
||||||
|
description: "Run /skill:ponytail-review",
|
||||||
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail-help", {
|
||||||
|
description: "Run /skill:ponytail-help",
|
||||||
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-help", "", ctx),
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("input", async (event) => {
|
||||||
|
if (event?.source === "extension") return;
|
||||||
|
|
||||||
|
const text = String(event?.text || "");
|
||||||
|
if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(text)) {
|
||||||
|
setMode("off");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
|
||||||
|
configuredDefaultMode = getDefaultMode();
|
||||||
|
currentMode = resolveSessionMode(entries, configuredDefaultMode);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event) => {
|
||||||
|
if (!currentMode || currentMode === "off") return;
|
||||||
|
return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail-pi-extension-dev",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test ./test/*.test.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import ponytailExtension from "../index.js";
|
||||||
|
|
||||||
|
function createPiHarness() {
|
||||||
|
const events = new Map();
|
||||||
|
const commands = new Map();
|
||||||
|
const appendedEntries = [];
|
||||||
|
const sentUserMessages = [];
|
||||||
|
|
||||||
|
const pi = {
|
||||||
|
on(eventName, handler) {
|
||||||
|
events.set(eventName, handler);
|
||||||
|
},
|
||||||
|
registerCommand(name, options) {
|
||||||
|
commands.set(name, options);
|
||||||
|
},
|
||||||
|
appendEntry(customType, data) {
|
||||||
|
appendedEntries.push({ customType, data });
|
||||||
|
},
|
||||||
|
sendUserMessage(text, options) {
|
||||||
|
sentUserMessages.push({ text, options });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
ponytailExtension(pi);
|
||||||
|
return { events, commands, appendedEntries, sentUserMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCommandContext(overrides = {}) {
|
||||||
|
return {
|
||||||
|
isIdle: () => true,
|
||||||
|
sessionManager: { getEntries: () => [] },
|
||||||
|
ui: { notify() {} },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTempConfig(fn) {
|
||||||
|
const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
|
||||||
|
const previousXdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
process.env.XDG_CONFIG_HOME = tempConfigHome;
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(fn)
|
||||||
|
.finally(() => {
|
||||||
|
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||||
|
else process.env.XDG_CONFIG_HOME = previousXdg;
|
||||||
|
rmSync(tempConfigHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("extension registers Ponytail commands", () => {
|
||||||
|
const { commands } = createPiHarness();
|
||||||
|
|
||||||
|
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-help", "ponytail-review"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
|
||||||
|
const { commands, events, appendedEntries } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "startup" }, ctx);
|
||||||
|
await commands.get("ponytail").handler("ultra", ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(appendedEntries.at(-1), {
|
||||||
|
customType: "ponytail-mode",
|
||||||
|
data: { mode: "ultra" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
assert.ok(result.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
|
||||||
|
assert.ok(result.systemPrompt.includes("ultra"));
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("session_start restores latest persisted mode", async () => withTempConfig(async () => {
|
||||||
|
const { events } = createPiHarness();
|
||||||
|
const ctx = createCommandContext({
|
||||||
|
sessionManager: {
|
||||||
|
getEntries: () => [
|
||||||
|
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "resume" }, ctx);
|
||||||
|
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
|
||||||
|
assert.ok(result.systemPrompt.includes("lite"));
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("skill alias commands delegate to Pi skill commands", async () => {
|
||||||
|
const { commands, sentUserMessages } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await commands.get("ponytail-review").handler("", ctx);
|
||||||
|
await commands.get("ponytail-help").handler("", ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
|
||||||
|
"/skill:ponytail-review",
|
||||||
|
"/skill:ponytail-help",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normal mode disables persistent instructions", async () => withTempConfig(async () => {
|
||||||
|
const { commands, events } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "startup" }, ctx);
|
||||||
|
await commands.get("ponytail").handler("ultra", ctx);
|
||||||
|
await events.get("input")({ text: "normal mode", source: "interactive" }, ctx);
|
||||||
|
|
||||||
|
const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
assert.equal(disabled, undefined);
|
||||||
|
}));
|
||||||
@@ -0,0 +1,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.`);
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -50,5 +50,7 @@ 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.
|
||||||
|
|||||||
@@ -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.
|
- 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
|
||||||
|
|
||||||
@@ -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,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');
|
||||||
@@ -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