Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b0b98e16 | ||
|
|
46c5c28b35 | ||
|
|
1556f10bc6 | ||
|
|
c15db8d3c9 |
@@ -1,35 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ponytail",
|
"name": "ponytail",
|
||||||
"version": "4.1.0",
|
"version": "4.2.0",
|
||||||
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
|
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Dietrich Gebert",
|
"name": "Dietrich Gebert",
|
||||||
"url": "https://github.com/DietrichGebert"
|
"url": "https://github.com/DietrichGebert"
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js",
|
|
||||||
"timeout": 5,
|
|
||||||
"statusMessage": "Loading ponytail mode..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js",
|
|
||||||
"timeout": 5,
|
|
||||||
"statusMessage": "Tracking ponytail mode..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ponytail",
|
"name": "ponytail",
|
||||||
"version": "4.1.0",
|
"version": "4.2.0",
|
||||||
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
|
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Dietrich Gebert",
|
"name": "Dietrich Gebert",
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -79,6 +79,16 @@ open `/hooks`, review and trust its two lifecycle hooks, and start a new thread.
|
|||||||
pi install git:github.com/DietrichGebert/ponytail
|
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.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ to load in a given agent.
|
|||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| Claude Code | `.claude-plugin/`, `commands/`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. |
|
| 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. |
|
| 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. |
|
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
|
||||||
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
|
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
|
||||||
| Cline | `.clinerules/ponytail.md` | Project rule. |
|
| Cline | `.clinerules/ponytail.md` | Project rule. |
|
||||||
|
|||||||
+4
-4
@@ -6,8 +6,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
|
||||||
"commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\ponytail-activate.js\"",
|
"commandWindows": "node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\ponytail-activate.js\"",
|
||||||
"timeout": 5,
|
"timeout": 5,
|
||||||
"statusMessage": "Loading ponytail mode..."
|
"statusMessage": "Loading ponytail mode..."
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
|
||||||
"commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\ponytail-mode-tracker.js\"",
|
"commandWindows": "node \"%CLAUDE_PLUGIN_ROOT%\\hooks\\ponytail-mode-tracker.js\"",
|
||||||
"timeout": 5,
|
"timeout": 5,
|
||||||
"statusMessage": "Tracking ponytail mode..."
|
"statusMessage": "Tracking ponytail mode..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,24 @@ function filterSkillBodyForMode(body, mode) {
|
|||||||
const effectiveMode = normalizeMode(mode) || DEFAULT_MODE;
|
const effectiveMode = normalizeMode(mode) || DEFAULT_MODE;
|
||||||
const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, '');
|
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
|
return withoutFrontmatter
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter((line) => {
|
.filter((line) => {
|
||||||
const tableMatch = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/);
|
const tableLabel = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/);
|
||||||
if (tableMatch) return tableMatch[1].trim() === effectiveMode;
|
if (tableLabel) {
|
||||||
|
const labelMode = normalizeMode(tableLabel[1].trim());
|
||||||
|
if (labelMode) return labelMode === effectiveMode;
|
||||||
|
}
|
||||||
|
|
||||||
const exampleMatch = line.match(/^-\s*([^:]+):\s*/);
|
const exampleLabel = line.match(/^-\s*([^:]+):\s*/);
|
||||||
if (exampleMatch) return exampleMatch[1].trim() === effectiveMode;
|
if (exampleLabel) {
|
||||||
|
const labelMode = normalizeMode(exampleLabel[1].trim());
|
||||||
|
if (labelMode) return labelMode === effectiveMode;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"plugin": ["./.opencode/plugins/ponytail.mjs"]
|
||||||
|
}
|
||||||
@@ -66,3 +66,20 @@ test("filterSkillBodyForMode keeps only requested intensity examples and rows",
|
|||||||
assert.ok(filtered.includes("Ultra example"));
|
assert.ok(filtered.includes("Ultra example"));
|
||||||
assert.ok(filtered.includes("Other line"));
|
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'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ Level sticks until changed or session end.
|
|||||||
| **ponytail-help** | `/ponytail-help` | This card. |
|
| **ponytail-help** | `/ponytail-help` | This card. |
|
||||||
|
|
||||||
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
||||||
uses the slash-command forms above.
|
and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
|
||||||
|
`/ponytail-review`).
|
||||||
|
|
||||||
## Deactivate
|
## Deactivate
|
||||||
|
|
||||||
|
|||||||
@@ -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