Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .opencode/command/ponytail-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description: Review changes for over-engineering — what can be deleted
---

Review the current code changes for over-engineering only — not correctness. One line per finding: L<line>: <tag> <what to cut>. <replacement>. Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines removable. If nothing to cut: 'Lean already. Ship.'
5 changes: 5 additions & 0 deletions .opencode/command/ponytail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description: Switch ponytail intensity level (lite/full/ultra/off)
---

Switch to ponytail $ARGUMENTS mode. If no level specified, use full. Lazy senior dev mode — before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment.
65 changes: 65 additions & 0 deletions .opencode/plugins/ponytail.mjs
Original file line number Diff line number Diff line change
@@ -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);
},
};
};
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

### OpenCode

Run OpenCode from a checkout of this repo (the plugin reuses its `hooks/` and `skills/`), and add to `opencode.json`:

```json
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] }
```

Injects the ruleset every turn at the active level; adds `/ponytail` and `/ponytail-review`. OpenCode also auto-loads this repo's `AGENTS.md`, so the rules hold even without the plugin — the plugin adds the `lite/full/ultra/off` levels.

That was it. He'd be proud. He won't say it.

Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest.
Expand Down
1 change: 1 addition & 0 deletions docs/agent-portability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| 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. |
Expand Down
4 changes: 4 additions & 0 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["./.opencode/plugins/ponytail.mjs"]
}
3 changes: 2 additions & 1 deletion skills/ponytail-help/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ Level sticks until changed or session end.
| **ponytail-help** | `/ponytail-help` | This card. |

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

Expand Down
64 changes: 64 additions & 0 deletions tests/opencode-plugin.test.js
Original file line number Diff line number Diff line change
@@ -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 }));