Skip to content
Open
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
9 changes: 5 additions & 4 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ PONYTAIL_PLUGIN_DIR=/abs/path/to/your-branch python run.py --task <task> --arms
If your task isn't in `tasks.py` yet, add it with a `good` and a `bad` reference, so `--selftest`
proves the scorer catches the difference.

Rule text lives in `AGENTS.md` and `skills/ponytail/SKILL.md`. Keep the copies in sync
(`node scripts/check-rule-copies.js`) and regenerate `.openclaw/` with
`node scripts/build-openclaw-skills.js`.
Rule text lives in `skills/ponytail/SKILL.md`. Regenerate `AGENTS.md` and the host
copies with `node scripts/generate-rule-copies.js` (CI checks they match), and
regenerate `.openclaw/` with `node scripts/build-openclaw-skills.js`.

## Everything else

Expand All @@ -60,9 +60,10 @@ Keep the PR to one change and link the issue it fixes.

## Development

When changing the compact rule text, keep the agent copies aligned:
When changing the ruleset, edit `skills/ponytail/SKILL.md` only, then regenerate:

```bash
node scripts/generate-rule-copies.js
node scripts/check-rule-copies.js
npm test
```
Expand Down
28 changes: 18 additions & 10 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ROOT = Path(__file__).resolve().parent
SKILLS_DIR = ROOT / "skills"
PONYTAIL_SKILL = SKILLS_DIR / "ponytail" / "SKILL.md"
AGENTS_PATH = ROOT / "AGENTS.md"

_current_mode = None

Expand Down Expand Up @@ -87,16 +88,23 @@ def _filter_skill_body_for_mode(body: str, mode: str) -> str:


def _fallback_instructions(mode: str) -> str:
return (
f"PONYTAIL MODE ACTIVE — level: {mode}\n\n"
"You are a lazy senior developer. The best code is the code never written. "
"Before you write, list every place your change must reach (callers, tests, "
"fixtures) and what it could break for users. First rung that works: skip what "
"is not needed, reuse what the codebase has, stdlib or platform, installed "
"dependency, one readable line, then minimum code. No unrequested abstractions, "
"wrappers, options or boilerplate. Never cut trust-boundary validation, "
"data-loss handling, security, accessibility or anything asked for."
)
# Prefer generated AGENTS.md so Hermes does not keep a third ruleset copy.
header = f"PONYTAIL MODE ACTIVE — level: {mode}\n\n"
try:
agents = AGENTS_PATH.read_text(encoding="utf-8").replace("\r\n", "\n")
agents = re.sub(
r"\n\n\(Yes, this file also applies[\s\S]*?\)\s*$",
"",
agents,
).strip()
return header + agents
except OSError:
return (
header
+ "You are a lazy senior developer. The best code is the code never written. "
"Solve the whole problem with the least new code. Never cut trust-boundary "
"validation, data-loss handling, security, accessibility, or anything asked for."
)


def build_injected_context(mode: str | None = None) -> str:
Expand Down
62 changes: 10 additions & 52 deletions hooks/band/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
// No server and no network: inline CSS, inline SVG and a few lines of script.

import { LOGO_PATHS, LOGO_VIEWBOX } from './logo'
import {
escapeJsonForScript,
parseCsv as parseCsvJs,
tasksFrom as tasksFromJs,
} from './csv.js'

export const duration = (minutes: number) =>
minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
Expand Down Expand Up @@ -31,56 +36,9 @@ export type Task = {
model: string
}

// RFC 4180 rows: quoted fields may hold commas, doubled quotes and line breaks.
export function parseCsv(text: string): string[][] {
const rows: string[][] = []
let row: string[] = []
let field = ''
let quoted = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"'
i++
} else if (c === '"') quoted = false
else field += c
} else if (c === '"') quoted = true
else if (c === ',') {
row.push(field)
field = ''
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++
row.push(field)
if (row.some(f => f !== '')) rows.push(row)
row = []
field = ''
} else field += c
}
row.push(field)
if (row.some(f => f !== '')) rows.push(row)
return rows
}

export function tasksFrom(text: string): Task[] {
const [head, ...rows] = parseCsv(text)
if (!head?.includes('key')) return []
const at = (row: string[], col: string) => row[head.indexOf(col)] ?? ''
const num = (row: string[], col: string) => Number(at(row, col)) || 0
return rows.map(row => ({
started: at(row, 'started'),
project: at(row, 'project'),
name: at(row, 'name'),
cost: num(row, 'cost_usd'),
turns: num(row, 'turns'),
agentMinutes: num(row, 'agent_minutes'),
tokens: num(row, 'tokens_total'),
reuse: at(row, 'reuse_pct') === '' ? null : num(row, 'reuse_pct'),
tests: at(row, 'tests'),
level: at(row, 'level'),
model: at(row, 'model'),
}))
}
// Re-export the Node-tested pure helpers so Claude Code and metrics keep one import path.
export const parseCsv = parseCsvJs as (text: string) => string[][]
export const tasksFrom = tasksFromJs as (text: string) => Task[]

const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const day = (ms: number) => {
Expand Down Expand Up @@ -520,8 +478,8 @@ const CARD = `<svg id="card" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120
${cardStat(172, 'Cost', 'c-cost')}${cardStat(228, 'Tokens', 'c-tokens')}${cardStat(284, 'Sessions', 'c-sessions')}
<text x="72" y="621" font-family="${MONO}" font-size="15" fill="#5e5c55">measured in Claude Code</text><text x="1128" y="621" text-anchor="end" font-family="${MONO}" font-size="15" fill="#5fe08a">ponytail.dev</text></svg>`

// JSON inside a script block: "<" is escaped so no name can close the block.
const json = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
// JSON inside a script block: shared helper escapes "<" so no name can close the block.
const json = escapeJsonForScript

// Dark, light, system: CSS shows the icon of the current choice.
const THEME_BUTTON = '<button type="button" class="theme" id="theme" data-choice="dark" aria-label="Theme: dark" title="Theme: dark"><svg class="i-system" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><svg class="i-light" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg><svg class="i-dark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg></button>'
Expand Down
78 changes: 78 additions & 0 deletions hooks/band/csv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Pure CSV + script-JSON helpers for the band board.
// Kept as plain JS so Node CI can test them without Claude Code's TS loader.

/** @typedef {{
* started: string,
* project: string,
* name: string,
* cost: number,
* turns: number,
* agentMinutes: number,
* tokens: number,
* reuse: number | null,
* tests: string,
* level: string,
* model: string,
* }} Task */

// RFC 4180 rows: quoted fields may hold commas, doubled quotes and line breaks.
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"') quoted = true;
else if (c === ',') {
row.push(field);
field = '';
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
row.push(field);
if (row.some(f => f !== '')) rows.push(row);
row = [];
field = '';
} else field += c;
}
row.push(field);
if (row.some(f => f !== '')) rows.push(row);
return rows;
}

/** @param {string} text @returns {Task[]} */
function tasksFrom(text) {
const [head, ...rows] = parseCsv(text);
if (!head || !head.includes('key')) return [];
const at = (row, col) => row[head.indexOf(col)] ?? '';
const num = (row, col) => Number(at(row, col)) || 0;
return rows.map(row => ({
started: at(row, 'started'),
project: at(row, 'project'),
name: at(row, 'name'),
cost: num(row, 'cost_usd'),
turns: num(row, 'turns'),
agentMinutes: num(row, 'agent_minutes'),
tokens: num(row, 'tokens_total'),
reuse: at(row, 'reuse_pct') === '' ? null : num(row, 'reuse_pct'),
tests: at(row, 'tests'),
level: at(row, 'level'),
model: at(row, 'model'),
}));
}

// JSON inside a <script> block: "<" is escaped so no name can close the block.
function escapeJsonForScript(value) {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}

module.exports = { parseCsv, tasksFrom, escapeJsonForScript };
48 changes: 17 additions & 31 deletions hooks/ponytail-instructions.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponyt

const INDEPENDENT_MODES = new Set(['review']);
const SKILL_PATH = path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md');
const AGENTS_PATH = path.join(__dirname, '..', 'AGENTS.md');

function filterSkillBodyForMode(body, mode) {
const effectiveMode = normalizeMode(mode) || DEFAULT_MODE;
Expand Down Expand Up @@ -41,37 +42,22 @@ function filterSkillBodyForMode(body, mode) {
}

function getFallbackInstructions(mode) {
// Used only when SKILL.md cannot be read: the same rules as AGENTS.md.
return 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' +
'Switch level: `/ponytail lite|full|ultra`. Off: "stop ponytail" or "normal mode".\n\n' +
"You are a lazy senior developer. The best code is the code never written. You solve the whole problem with the least new code. End your reply with one or two lines: what you skipped or did not check, and any risk the user must know.\n" +
"\n" +
"## Before you write\n" +
"\n" +
"Read the task and the code it touches. List every place your change must reach: callers, tests, fixtures, config, exports. Check what your change could break for users: data it would destroy or expose, callers that stop working. That is scope. Extra features are not.\n" +
"\n" +
"## The smallest complete change\n" +
"\n" +
"Take the first option that fully works:\n" +
"\n" +
"1. Does it need to exist? Skip features, options and flexibility nobody asked for, and name them in one line. A vague request (\"build me X\") gets the smallest version that does the core job.\n" +
"2. Already in this codebase (a helper, component, service, pattern)? Use it the way the surrounding code does.\n" +
"3. Standard library or a platform feature? Use it, unless the project has its own. A house component beats a native widget.\n" +
"4. An installed dependency? Use it. Never add a dependency for a few lines.\n" +
"5. Can it be one line a reader gets at a glance? One line.\n" +
"6. Otherwise: the minimum code that works.\n" +
"\n" +
"- Be lazy about the solution, never about the change itself: finish every part the task needs, including the callers, tests and fixtures your change breaks.\n" +
"- No abstraction, wrapper, type conversion, option, config, boilerplate or \"for later\" code nobody asked for. Keep values in the form the platform already gives you. Deletion beats addition. Keep the structure the codebase already has: its layers, interfaces and conventions.\n" +
"- The shortest working diff wins, once you know everything it must touch. A one-liner that needs decoding is not short.\n" +
"- Comment only the why the code cannot show, in one line.\n" +
"- Bug fix: before you edit, grep every caller of the function you touch, then fix the root cause once in the shared code.\n" +
"- Code you move or merge keeps its error handling and validation.\n" +
"- Between options of equal size, take the one that is correct on edge cases.\n" +
"- Lazy code without its check is unfinished: new non-trivial logic (a branch, a loop, a parser, money or security, or a whole new script or app) leaves one small test or an assert-based self-check. Trivial changes need none.\n" +
"- A shortcut with a known limit gets a code comment in this form: `shortcut: <the limit>, <when to upgrade>`.\n" +
"\n" +
"Never cut: validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs, anything the user asked for.\n";
// Used only when SKILL.md cannot be read. Prefer the generated AGENTS.md so
// the ruleset is not a third hand-maintained copy. Last resort: a one-line
// stub — never a full duplicate of SKILL.md.
const header = 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n';
try {
const agents = fs.readFileSync(AGENTS_PATH, 'utf8')
.replace(/\r\n/g, '\n')
.replace(/\n\n\(Yes, this file also applies[\s\S]*?\)\s*$/, '')
.trim();
return header + agents + '\n';
} catch (e) {
return header +
'You are a lazy senior developer. The best code is the code never written. ' +
'Solve the whole problem with the least new code. Never cut trust-boundary ' +
'validation, data-loss handling, security, accessibility, or anything asked for.\n';
}
}

function getPonytailInstructions(mode) {
Expand Down
51 changes: 19 additions & 32 deletions scripts/check-rule-copies.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,47 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { compactBody, renderCopies } = require('./generate-rule-copies');

const root = path.join(__dirname, '..');

function read(relPath) {
return fs.readFileSync(path.join(root, relPath), 'utf8').replace(/\r\n/g, '\n').trim();
return fs.readFileSync(path.join(root, relPath), 'utf8').replace(/\r\n/g, '\n');
}

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()],
['.agents/rules/ponytail.md', text => text.trim()],
['.qoder/rules/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`);
const expected = renderCopies();
for (const [relPath, content] of Object.entries(expected)) {
const actual = read(relPath);
if (actual !== content) {
console.error(`${relPath} is stale — run: node scripts/generate-rule-copies.js`);
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.
// SKILL.md is the hand-edited source. Pin load-bearing rules so a reword cannot
// silently drop one from the skill (or from the compact body derived from it).
const INVARIANTS = [
'in this codebase', // ladder rung: reuse what already exists (#217)
'grep every caller', // root-cause bug fix (#245)
'never about the change itself', // scope: callers, tests and fixtures the change breaks
'shortcut: <the limit>, <when to upgrade>', // shortcut marker comment
'correct on edge cases', // robust-variant rule
'Lazy code without its check is unfinished', // test reflex
// the "never cut" carve-outs: pin each so a reword in either file can't silently drop one.
// the "never cut" carve-outs: pin each so a reword can't silently drop one.
'validation at trust boundaries',
'prevents data loss',
'security',
'accessibility',
];

const skill = read('skills/ponytail/SKILL.md');
const sources = [['skills/ponytail/SKILL.md', skill], ['AGENTS.md', agents]];
const compact = compactBody();
const sources = [
['skills/ponytail/SKILL.md', skill],
['compact body (AGENTS.md)', compact],
];
for (const phrase of INVARIANTS) {
for (const [label, text] of sources) {
if (!text.includes(phrase)) {
Expand All @@ -67,8 +52,10 @@ for (const phrase of INVARIANTS) {
}

if (failed) {
console.error('Update the copied rule text, AGENTS.md, or SKILL.md so the shared rules match.');
console.error('Update skills/ponytail/SKILL.md, then run: node scripts/generate-rule-copies.js');
process.exit(1);
}

console.log(`Rule copies match AGENTS.md; ${INVARIANTS.length} rule invariants present in SKILL.md and AGENTS.md.`);
console.log(
`Rule copies match SKILL.md generator; ${INVARIANTS.length} rule invariants present in skill and compact body.`,
);
Loading