Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24b0b98e16 | ||
|
|
46c5c28b35 | ||
|
|
1556f10bc6 | ||
|
|
c15db8d3c9 | ||
|
|
8c279cbfb3 | ||
|
|
de318b9457 | ||
|
|
784e8bfd1c | ||
|
|
bce2162025 | ||
|
|
515fb4c5a4 | ||
|
|
e37b823b92 | ||
|
|
e89a4e9863 | ||
|
|
2f2a0d33e0 | ||
|
|
c16f967d37 |
@@ -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,35 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ponytail",
|
"name": "ponytail",
|
||||||
"version": "4.0.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..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -56,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
-96
@@ -10,121 +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. ' +
|
|
||||||
'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.';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'));
|
||||||
@@ -153,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`.
|
||||||
|
|||||||
@@ -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