Compare commits

..
Author SHA1 Message Date
EmerikoandClaude Opus 4.8 dae86adc22 docs: desktop install + OpenCode command linking
Documents the desktop-app install flow (no /plugin command) and the global command-dir linking needed for /ponytail commands in OpenCode outside a checkout. Covers the recurring questions in #97 and #98.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:29:50 +02:00
DietrichGebertandClaude Opus 4.8 adad50d9b3 chore: bump version to 4.7.0 (#103)
Bumps all four plugin manifests to 4.7.0 for the OpenClaw / ClawHub skill package (#102).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:56:32 +02:00
DietrichGebertandClaude Opus 4.8 41d6c2f761 feat: ship ponytail to OpenClaw (ClawHub skill package) (#102)
Adds .openclaw/skills/ (ponytail + review/audit/debt/help) generated from the canonical skills/ (verbatim body, no drift), a generator script, and a drift test. Verified live: loads as Ready in OpenClaw 2026.6.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:35:35 +02:00
DietrichGebertandClaude Opus 4.8 caf138df56 benchmarks: fix correctness gate + robustness audit (#65) (#83)
* fix(benchmarks): correctness gate scores unfenced code; fix debounce task

The `correct` gate under-reported correctness for terse models, the likely
source of "Ponytail degrades models" reports (issue #65):

- extractBlocks() only matched fenced code blocks, so bare/unfenced code
  scored an automatic fail even when correct. Now falls back to the whole
  response as one block (and tolerates CRLF). Debounce detection also accepts
  unfenced arrow functions.
- The debounce task asked to "add debounce to a search input" but the check
  expected a reusable debounce(fn, delay) util, failing correct inline answers.
  Task reworded to the deliverable the check verifies.

Adds correctness.test.js (regression guard) and a GPT-mini repro config plus
results writeup: on a clean n=20 run, the reported gpt-4.1-mini drop (10/15)
does not reproduce (100/100). The LOC win (~halved) holds.

README repro fixed: promptfoo needs --env-file ../.env (reads cwd, not root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(benchmarks): add robustness audit — ponytail vs baseline on edge cases

Answers the real question behind #65: does ponytail's push for the shortest
solution make weak models produce wrong code on edge cases?

robustness-audit.js: 16 self-verifying tasks (12 algorithmic edge-case traps +
4 validators). Each check ships a known-good and known-lazy-wrong reference that
must pass/fail before any model output is scored (--selftest, 16/16).

Findings (gpt-4.1-mini + gpt-5.4-mini, baseline vs ponytail): parity on every
edge-case trap on both models. The one measured soft spot is gpt-5.4-mini email
(~4-5%, reaches for parseaddr). A sharpened SKILL.md validation rule had no
reliable effect in an n=100 A/B (96% vs 95%), so it was not shipped — the
tendency is model-level, not skill-level. Full writeup in results/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(benchmarks): email slip is provider-specific — 100% on Claude

High-n cross-provider follow-up to the robustness audit. The one ponytail
soft spot (email validation via parseaddr) splits by provider, not model size:

- Claude (haiku/sonnet/opus): 100% under ponytail, n=40 each — and ponytail
  beats baseline (unconstrained Sonnet over-engineers into an always-truthy
  dict, 0/40; ponytail writes a clean validator).
- OpenAI (gpt-4.1-mini..gpt-5.5): slips at every size under ponytail
  (~79-98%), baseline ~100%. The parseaddr reflex lives in OpenAI training.

Not fixable by skill text: 8 distinct SKILL.md edits (incl. an n=100 A/B,
96% vs 95%) all scored <= current, several worse, all bloated LOC. Nothing
shipped. SKILL.md unchanged.

Conclusion: on ponytail's target platform (Claude) email is 100%; the GPT
slip is a documented cross-provider transfer quirk. Adds model-email.js /
claude-email.js to reproduce the tables. Writeup updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(benchmarks): correct misleading Sonnet baseline 0 percent

The Sonnet baseline 0/40 on email is a return-type artifact, not a logic
failure: unconstrained Sonnet returns a dict {is_valid, message} instead of a
bool, so the bool-contract gate scores every case as accepted. Read dict-aware
via is_valid, its logic is ~75% correct (9/12). Reframed honestly so we are not
presenting 0 vs 100 as a clean win; ponytail still wins (clean 100% bool) but
the point is over-engineered return type, not total failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:17:10 +02:00
DietrichGebertandClaude Opus 4.8 687c1b3398 docs: Antigravity CLI install, global default level, OpenCode absolute path (#73)
Documents the Antigravity CLI install, the global default-level config, and the OpenCode absolute-path option. Addresses #58, #64, #71.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:04:56 +02:00
DietrichGebertandClaude Opus 4.8 ce153bc95f chore: bump version to 4.6.0 (#72)
Bumps all four plugin manifests to 4.6.0 so /ponytail-help reaches the release-install hosts (Gemini CLI, Copilot CLI marketplace).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:37:06 +02:00
YIZIHN 084f10fb48 fix: ship the missing /ponytail-help command on Claude Code and OpenCode (#62)
Ships the previously-missing /ponytail-help adapter files (commands/ponytail-help.toml, .opencode/command/ponytail-help.md) and adds tests/commands.test.js, a parity guard asserting every pi-registered command has both adapter files. Thanks @hooni0918.
2026-06-15 16:32:02 +02:00
DietrichGebertandClaude Opus 4.8 2e6a93765a fix(benchmarks): count unfenced code, ASCII-safe output, refresh llama3.2 results (#67)
Fixes the local benchmark LOC counter (counted only fenced code, scored bare output 0), makes summary output ASCII-safe (a Unicode arrow crashed the script on Windows cp1252), gitignores generated artifacts, and refreshes the llama3.2 writeup with n=5 data showing the LOC effect is within the noise floor. Follow-up to #63. Verified live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:22:32 +02:00
Mandavilli Vijay 386f95734a benchmarks: add local model support and Node version note (#63)
Adds benchmarks/benchmark-local.py (Ollama-based local runner), a results writeup, and a Node version note. Thanks @mandavillivijay.
2026-06-15 15:27:11 +02:00
DietrichGebertandClaude Opus 4.8 60a75f8159 chore: bump version to 4.5.0 (#60)
Release-prep bump across all four plugin manifests (Claude Code, Codex,
Gemini, Copilot) for v4.5.0. The cross-manifest parity test keeps them aligned.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:49:37 +02:00
DietrichGebert b41cb8d3af docs: note the Codex install also covers the desktop app (#59) 2026-06-15 11:25:35 +02:00
DietrichGebertandClaude Opus 4.8 d676635325 fix: hooks degrade gracefully when node is not on PATH (#57)
Claude Code runs hooks via a non-interactive /bin/sh. On setups where node
isn't on that shell's PATH (Nix/nix-darwin, nvm, fnm), every prompt errored
with "/bin/sh: node: command not found". Guard each hook command so it runs
node only when present and exits 0 otherwise, no more per-prompt noise. The
slash-command skills are unaffected; only the always-on activation needs node.
Document the requirement in the README install section.

Closes #51.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:23:59 +02:00
Christopher MayfieldandCursor f02f9424a5 fix: use python3 for correctness checks and add CI (#50)
The benchmark harness hardcoded `python`, which is missing on macOS and
many Linux images. Probe python3 first, add npm test, and run checks in
GitHub Actions so regressions are caught on every PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 11:11:04 +02:00
DietrichGebert 2302fbc843 docs: bump the agents badge to 13 (#56)
The badge had fallen behind: it stayed at 11 when Antigravity and the VS Code
Codex extension were added, and Copilot CLI is now a full plugin host too. 13
distinct agent rows in docs/agent-portability.md (excluding the generic
fallback).
2026-06-15 11:07:04 +02:00
c1c80f3cc8 Adding support for Copilot Marketplace plugin (#47)
* Add GitHub Copilot plugin and marketplace manifests for Ponytail

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Copilot hook adapters and plugin data runtime precedence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Document Copilot plugin install flow and instruction fallback mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Copilot hooks for native output context and state-only mode tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: add Copilot CLI namespaced command examples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Collapse Copilot hooks into shared activate/mode-tracker

The Copilot hook files duplicated ponytail-activate.js and
ponytail-mode-tracker.js, differing only in output shape. Move that
difference into writeHookOutput (isCopilot branch) and point
copilot-hooks.json at the shared hooks. Deletes both forks (-73 lines).

Refs #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align Copilot manifest version to 4.4.0 with cross-manifest parity test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Make Copilot and Codex host detection exclusive in runtime output routing

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Copilot debt command validation with a pull request acceptance checklist

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Removed PR template

* Drop tautological copilot command-form test

The namespaced-form assertion built '/ponytail:ponytail-debt' from two
constants and compared it to itself — it tests string concatenation, not
wiring. The file-exists check above already catches a renamed manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:02:59 +02:00
39 changed files with 1539 additions and 37 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.4.0",
"version": "4.7.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.4.0",
"version": "4.7.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"owner": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"plugins": [
{
"name": "ponytail",
"description": "Forces the laziest solution that works. YAGNI, stdlib first, one line over fifty.",
"source": "./",
"category": "productivity",
"tags": ["yagni", "minimalism", "code-review", "productivity"],
"commands": "commands/",
"skills": "skills/",
"hooks": "hooks/copilot-hooks.json"
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"version": "4.7.0",
"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"],
"commands": "commands/",
"skills": "skills/",
"hooks": "hooks/copilot-hooks.json"
}
+29
View File
@@ -0,0 +1,29 @@
name: test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Python deps for correctness checks
run: pip install pandas
- name: Check rule copies
run: node scripts/check-rule-copies.js
- name: Run tests
run: npm test
+4
View File
@@ -9,6 +9,10 @@ node_modules/
# promptfoo eval artifacts
.promptfoo/
benchmarks/output*
benchmarks/benchmark-local-results.json
# Python
__pycache__/
# one-off social/announcement art, not repo content
announce-*.png
+36
View File
@@ -0,0 +1,36 @@
---
name: ponytail-audit
description: "Audit the whole repo for over-engineering. A ranked list of what to delete, simplify, or replace with stdlib or native features."
homepage: https://github.com/DietrichGebert/ponytail
license: MIT
---
ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank
findings biggest cut first.
## Tags
Same as ponytail-review:
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
- `native:` dependency or code doing what the platform already does. Name the feature.
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
- `shrink:` same logic, fewer lines. Show the shorter form.
## Hunt
Deps the stdlib or platform already ships, single-implementation interfaces,
factories with one product, wrappers that only delegate, files exporting one
thing, dead flags and config, hand-rolled stdlib.
## Output
One line per finding, ranked: `<tag> <what to cut>. <replacement>. [path]`.
End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. Ship.`
## Boundaries
Complexity only, correctness bugs, security holes, and performance go to a
normal review pass. Lists findings, applies nothing. One-shot.
"stop ponytail-audit" or "normal mode" to revert.
+41
View File
@@ -0,0 +1,41 @@
---
name: ponytail-debt
description: "Harvest every ponytail: shortcut comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report."
homepage: https://github.com/DietrichGebert/ponytail
license: MIT
---
Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming
its ceiling and upgrade path. This collects them into one ledger so a deferral
can't quietly become permanent.
## Scan
Grep the repo for comment markers, skipping `node_modules`, `.git`, and build
output:
`grep -rnE '(#|//) ?ponytail:' .` (add other comment prefixes if your stack uses them)
Each hit is one ledger row. The comment prefix keeps prose that merely mentions
the convention out of the ledger.
## Output
One row per marker, grouped by file:
`<file>:<line> — <what was simplified>. ceiling: <the limit named>. upgrade: <the trigger to revisit>.`
The convention is `ponytail: <ceiling>, <upgrade path>`, so pull the ceiling
and the trigger straight from the comment. Want an owner per row too? add
`git blame -L<line>,<line>`.
Flag the rot risk: any `ponytail:` comment that names no upgrade path or
trigger gets a `no-trigger` tag, those are the ones that silently rot.
End with `<N> markers, <M> with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.`
## Boundaries
Reads and reports only, changes nothing. To persist it, ask and it writes the
ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or
"normal mode" to revert.
+67
View File
@@ -0,0 +1,67 @@
---
name: ponytail-help
description: "Quick reference for ponytail's modes, skills, and commands. One-shot display."
homepage: https://github.com/DietrichGebert/ponytail
license: MIT
---
# Ponytail Help
Display this reference card when invoked. One-shot, do NOT change mode,
write flag files, or persist anything.
## Levels
| Level | Trigger | What change |
|-------|---------|-------------|
| **Lite** | `/ponytail lite` | Build what's asked, name the lazier alternative in one line. |
| **Full** | `/ponytail` | The ladder enforced: YAGNI → stdlib → native → one line → minimum. Default. |
| **Ultra** | `/ponytail ultra` | YAGNI extremist. Deletion before addition. Challenges requirements before building. |
Level sticks until changed or session end.
## Skills
| Skill | Trigger | What it does |
|-------|---------|--------------|
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
| **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
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
`/ponytail off` also works.
## Configure Default Mode
Default mode = `full`, auto-active every session. Change it:
**Environment variable** (highest priority):
```bash
export PONYTAIL_DEFAULT_MODE=ultra
```
**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`):
```json
{ "defaultMode": "lite" }
```
Set `"off"` to disable auto-activation on session start, activate manually
with `/ponytail` when wanted.
Resolution: env var > config file > `full`.
## Update
Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`.
If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow.
## More
Full docs + examples: https://github.com/DietrichGebert/ponytail
+51
View File
@@ -0,0 +1,51 @@
---
name: ponytail-review
description: "Review a diff for over-engineering. Finds what to delete: reinvented stdlib, needless deps, speculative abstractions. One line per finding."
homepage: https://github.com/DietrichGebert/ponytail
license: MIT
---
Review diffs for unnecessary complexity. One line per finding: location, what
to cut, what replaces it. The diff's best outcome is getting shorter.
## Format
`L<line>: <tag> <what>. <replacement>.`, or `<file>:L<line>: ...` for
multi-file diffs.
Tags:
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
- `native:` dependency or code doing what the platform already does. Name the feature.
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
- `shrink:` same logic, fewer lines. Show the shorter form.
## Examples
❌ "This EmailValidator class might be more complex than necessary, have you
considered whether all these validation rules are needed at this stage?"
`L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.`
`L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.`
`repo.py:L88: yagni: AbstractRepository with one implementation. Inline it until a second one exists.`
`L52-71: delete: retry wrapper around an idempotent local call. Nothing replaces it.`
`L30-44: shrink: manual loop builds dict. dict(zip(keys, values)), 1 line.`
## Scoring
End with the only metric that matters: `net: -<N> lines possible.`
If there is nothing to cut, say `Lean already. Ship.` and stop.
## Boundaries
Complexity only, correctness bugs, security holes, and performance go to a
normal review pass, not this one. A single smoke test or `assert`-based
self-check is the ponytail minimum, not bloat, never flag it for deletion.
Does not apply the fixes, only lists them.
"stop ponytail-review" or "normal mode": revert to verbose review style.
+92
View File
@@ -0,0 +1,92 @@
---
name: ponytail
description: "Lazy senior dev mode. Forces the simplest, shortest solution that works: YAGNI, stdlib first, no unrequested abstractions."
homepage: https://github.com/DietrichGebert/ponytail
license: MIT
---
# Ponytail
You are a lazy senior developer. Lazy means efficient, not careless. You have
seen every over-engineered codebase and been paged at 3am for one. The best
code is the code never written.
## Persistence
ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
Switch: `/ponytail lite|full|ultra`.
## The ladder
Stop at the first rung that holds:
1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
2. **Stdlib does it?** Use it.
3. **Native platform feature covers it?** `<input type="date">` over a picker lib, CSS over JS, DB constraint over app code.
4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
5. **Can it be one line?** One line.
6. **Only then:** the minimum code that works.
The ladder is a reflex, not a research project. Two rungs work → take the
higher one and move on. The first lazy solution that works is the right one.
## Rules
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
- No boilerplate, no scaffolding "for later", later can scaffold for itself.
- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
- Fewest files possible. Shortest working diff wins.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
## Output
Code first. Then at most three short lines: what was skipped, when to add it.
No essays, no feature tours, no design notes. If the explanation is longer
than the code, delete the explanation, every paragraph defending a
simplification is complexity smuggled back in as prose. Explanation the user
explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
give it in full, the rule is only against unrequested prose.
Pattern: `[code] → skipped: [X], add when [Y].`
## Intensity
| Level | What change |
|-------|------------|
| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
Example: "Add a cache for these API responses."
- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
## When NOT to be lazy
Never simplify away: input validation at trust boundaries, error handling
that prevents data loss, security measures, accessibility basics, anything
explicitly requested. User insists on the full version → build it, no
re-arguing.
Hardware is never the ideal on paper: a real clock drifts, a real sensor
reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
just less code, the physical world needs tuning a minimal model can't see.
Lazy code without its check is unfinished. Non-trivial logic (a branch, a
loop, a parser, a money/security path) leaves ONE runnable check behind, the
smallest thing that fails if the logic breaks: an `assert`-based
`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
fixtures, no per-function suites unless asked. Trivial one-liners need no
test, YAGNI applies to tests too.
## Boundaries
Ponytail governs what you build, not how you talk (pair with Caveman for
terse prose). "stop ponytail" / "normal mode": revert. Level persists until
changed or session end.
The shortest path to done is the right path.
+5
View File
@@ -0,0 +1,5 @@
---
description: Quick reference for ponytail levels, skills, and commands
---
Show the ponytail quick reference. One shot, change nothing: do not switch mode, write flag files, or persist anything. Levels: /ponytail lite (build what's asked, name the lazier alternative in one line), /ponytail (full, the default ladder: YAGNI then stdlib then native then one line then minimum), /ponytail ultra (deletion before addition, challenges the requirement before building). Commands: /ponytail-review (over-engineering review of the current changes), /ponytail-audit (whole-repo over-engineering audit), /ponytail-debt (harvest ponytail: comments into a tracked ledger), /ponytail-help (this card). Deactivate with 'stop ponytail', 'normal mode', or /ponytail off; resume anytime with /ponytail. Default mode is full; change it with the PONYTAIL_DEFAULT_MODE environment variable (off|lite|full|ultra) or a config file at ~/.config/ponytail/config.json (Windows: %APPDATA%\ponytail\config.json) with {"defaultMode": "lite"}. Resolution order: env var, then config file, then full.
+61 -5
View File
@@ -14,7 +14,7 @@
<p align="center">
<img src="https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat-square&color=111111&label=stars" alt="Stars">
<img src="https://img.shields.io/github/v/release/DietrichGebert/ponytail?style=flat-square&color=111111&label=release" alt="Release">
<img src="https://img.shields.io/badge/works%20with-11%20agents-111111?style=flat-square" alt="Works with 11 agents">
<img src="https://img.shields.io/badge/works%20with-13%20agents-111111?style=flat-square" alt="Works with 13 agents">
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
</p>
@@ -71,6 +71,8 @@ Lazy, not negligent: trust-boundary validation, data-loss handling, security, an
The most effort ponytail will ever ask of you:
The Claude Code and Codex plugins run two tiny Node.js lifecycle hooks, so `node` needs to be on your PATH (note for Nix/nvm users: it must be on the non-interactive shell's PATH). If it isn't, the skills still work, the always-on activation just stays quiet instead of erroring on every prompt.
### Claude Code
```
@@ -78,6 +80,8 @@ The most effort ponytail will ever ask of you:
/plugin install ponytail@ponytail
```
The desktop app has no `/plugin` command. Install it from the UI instead: Customize, the + by personal plugins, Create plugin and add marketplace, Add from repository, then enter the repo URL (thanks @NiklasDHahn, #98).
### Codex
```bash
@@ -88,6 +92,29 @@ 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.
This same install also covers the Codex desktop app: restart the app after installing and it picks up the plugin.
### GitHub Copilot CLI
```bash
copilot plugin marketplace add DietrichGebert/ponytail
copilot plugin install ponytail@ponytail
```
In an interactive Copilot CLI session, use the slash equivalents:
```
/plugin marketplace add DietrichGebert/ponytail
/plugin install ponytail@ponytail
```
Copilot CLI namespaces plugin commands by plugin name. For example:
```text
/ponytail:ponytail ultra
/ponytail:ponytail-review
```
### Pi agent harness
```
@@ -104,6 +131,10 @@ Run OpenCode from a checkout of this repo (the plugin reuses its `hooks/` and `s
Injects the ruleset every turn at the active level; adds the `/ponytail` commands (see [Commands](#commands)). 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.
The `./` path resolves against your project's `opencode.json`; to share one checkout across projects, point it at the absolute path of the `.mjs` instead (it finds its `hooks/` and `skills/` relative to its own file).
The plugin path loads the ruleset everywhere, but the `/ponytail` commands are separate files in `.opencode/command/` that OpenCode only discovers from your project or the global commands dir. To use them outside this checkout, link them once: `ln -sf /absolute/path/to/ponytail/.opencode/command/* ~/.config/opencode/command/`.
### Gemini CLI
```bash
@@ -112,17 +143,37 @@ gemini extensions install https://github.com/DietrichGebert/ponytail
Loads the ruleset as always-on context every session and registers the `/ponytail` commands; the `skills/` ship too, activated when a task needs them.
### Antigravity CLI
Google is renaming Gemini CLI to Antigravity CLI (the `agy` binary); the same extension installs there:
```bash
agy plugin install https://github.com/DietrichGebert/ponytail
```
It reuses this repo's `gemini-extension.json`. One difference: Antigravity converts the `/ponytail` commands into skills, so you type them into the chat (e.g. `/ponytail-review` as a message) instead of picking them from a slash menu. Until the migration completes (around June 18, 2026), `gemini extensions install` still works too. To run it as an always-on rule instead, drop the ruleset into `.agents/rules/`.
### OpenClaw
```bash
clawhub install ponytail
```
Installs ponytail as an OpenClaw skill from ClawHub; the review, audit, debt, and help skills install the same way (`clawhub install ponytail-review`, and so on). OpenClaw applies it on coding tasks and also exposes it as a `/ponytail` command. Without ClawHub, copy [`.openclaw/skills/ponytail`](.openclaw/skills/) into `~/.openclaw/skills/`.
That was it. He'd be proud. He won't say it.
Active every session, with a handful of commands (see [Commands](#commands)). `/ponytail ultra` exists for when the codebase has wronged you personally. 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/)).
Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`.
Cursor, Windsurf, Cline, GitHub Copilot (editor), 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.
GitHub Copilot CLI: it already reads `AGENTS.md` and `.github/copilot-instructions.md` in a project, or copy the rules into `~/.copilot/copilot-instructions.md` to run ponytail in every project.
GitHub Copilot CLI fallback (instruction-only mode): it reads `AGENTS.md` and `.github/copilot-instructions.md` in a project, or copy the rules into `~/.copilot/copilot-instructions.md` to run ponytail in every project. This path keeps always-on guidance, but does not add plugin mode switches or hooks.
Antigravity and VS Code with the Codex extension: both read `AGENTS.md`, which this repo ships, so it works from the repo root with no setup (`~/.codex/AGENTS.md` makes Codex global, `.agents/rules/` makes it an always-on rule in Antigravity).
VS Code with the Codex extension reads `AGENTS.md`, which this repo ships, so it works from the repo root with no setup (`~/.codex/AGENTS.md` makes Codex global).
Which files map to which agent: [Agent portability](docs/agent-portability.md).
@@ -144,12 +195,17 @@ When changing the compact rule text, keep the agent copies aligned:
```bash
node scripts/check-rule-copies.js
npm test
```
The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale.
The correctness benchmark spawns Python for email and CSV checks; `python3` is tried before `python`. CSV checks need `pandas` installed locally.
## FAQ
**Does it need a config file?**
No.
No. An optional `~/.config/ponytail/config.json` or `PONYTAIL_DEFAULT_MODE` env var can set the default level, but nothing is required.
**What if I really need the 120-line cache class?**
You don't. Insist anyway and he'll build it. Slowly. Correctly. While looking at you.
+22 -1
View File
@@ -4,12 +4,33 @@ Three arms (no skill, [caveman](https://github.com/JuliusBrussee/caveman), ponyt
## Reproduce
### Claude (Haiku / Sonnet / Opus)
Requires an Anthropic API key and **Node.js ≥ 22.22.0** (promptfoo's engine constraint —
check with `node --version` and upgrade if needed):
```bash
cp ../.env.example ../.env # add your ANTHROPIC_API_KEY
npx promptfoo@latest eval -c promptfooconfig.yaml --repeat 10
npx promptfoo@latest eval -c promptfooconfig.yaml --env-file ../.env --repeat 10
npx promptfoo@latest view
```
`--env-file ../.env` is required because promptfoo reads `.env` from the current
directory (`benchmarks/`), not the repo root where the file lives.
### Local models via Ollama
No API key or promptfoo required. Runs against any model served by Ollama:
```bash
ollama pull llama3.2 # or any other model
python benchmarks/benchmark-local.py --model llama3.2 --repeat 3
```
See `benchmarks/results/2026-06-15-llama3.2-local.md` for what to expect: the skill works
well on instruction-following models (Claude-class) but transfers poorly to small local
models where the multi-step decision ladder isn't reliably followed.
Tasks: email validator, JS debounce, CSV sum, React countdown, FastAPI rate-limit (see `promptfooconfig.yaml`). Single-shot completions, default temperature.
## Median results (10 runs, 2026-06-13)
+156
View File
@@ -0,0 +1,156 @@
"""
Ponytail local benchmark — runs the same 5 tasks against any Ollama model.
No promptfoo required. Compares baseline vs caveman vs ponytail on code LOC
and wall-clock time. Results are printed as a table and saved to a JSON file.
Usage:
python benchmarks/benchmark-local.py
python benchmarks/benchmark-local.py --model llama3.2 --repeat 3
Prerequisites: Ollama running locally (https://ollama.com), model pulled.
"""
import argparse
import json
import re
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).parent.parent
TASKS = [
("email", "Write me a Python function that validates email addresses."),
("debounce", "Add debounce to a search input in vanilla JavaScript. It currently fires an API call on every keystroke."),
("csv-sum", "Write Python code that reads sales.csv and sums the 'amount' column."),
("countdown", "Build me a countdown timer component in React that counts down from a given number of seconds."),
("rate-limit", "Add rate limiting to my FastAPI endpoint so users can't spam it."),
]
def load_arms():
return {
"baseline": None,
"caveman": (ROOT / "benchmarks/arms/caveman-SKILL.md").read_text(encoding="utf-8"),
"ponytail": (ROOT / "skills/ponytail/SKILL.md").read_text(encoding="utf-8"),
}
def count_loc(text):
"""Non-blank, non-comment lines of code: fenced blocks, or the whole
response when the model emitted bare code with no fence."""
blocks = re.findall(r"```[a-zA-Z0-9_+\-]*\n([\s\S]*?)```", text)
lines = ("\n".join(blocks) if blocks else text).splitlines()
return sum(
1 for l in lines
if l.strip()
and not l.strip().startswith("//")
and not l.strip().startswith("#")
and l.strip() not in ("*/",)
and not l.strip().startswith("/*")
and not l.strip().startswith("*")
)
def call_ollama(model, system_prompt, user_prompt, ollama_url):
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_prompt})
payload = json.dumps({
"model": model,
"messages": messages,
"stream": False,
"options": {"temperature": 0.7},
}).encode()
req = urllib.request.Request(
f"{ollama_url}/api/chat",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.time()
with urllib.request.urlopen(req, timeout=180) as resp:
data = json.loads(resp.read())
elapsed = time.time() - t0
return data["message"]["content"], round(elapsed, 1)
def run(model, repeat, ollama_url):
arms = load_arms()
task_ids = [t[0] for t in TASKS]
# results[arm][task_id] = list of {loc, time}
results = {arm: {t: [] for t in task_ids} for arm in arms}
total = len(arms) * len(TASKS) * repeat
done = 0
for r in range(repeat):
for arm, system in arms.items():
for task_id, task_prompt in TASKS:
done += 1
label = f"[{done}/{total}] run{r+1} {arm:10s} / {task_id}"
print(f"{label} ...", end=" ", flush=True)
response, elapsed = call_ollama(model, system, task_prompt, ollama_url)
loc = count_loc(response)
results[arm][task_id].append({"loc": loc, "time": elapsed, "response": response})
print(f"{loc} LOC {elapsed}s")
# compute medians
def median(vals):
s = sorted(vals)
n = len(s)
return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
med_loc = {arm: {t: median([r["loc"] for r in results[arm][t]]) for t in task_ids} for arm in arms}
med_time = {arm: {t: median([r["time"] for r in results[arm][t]]) for t in task_ids} for arm in arms}
col = 12
header = f"{'arm':<12}" + "".join(f"{t:>{col}}" for t in task_ids) + f"{'TOTAL':>{col}}"
sep = "-" * len(header)
print(f"\n{'=' * 60}")
print(f" RESULTS - {model} (n={repeat}, median)")
print(f"{'=' * 60}")
print(f"\nCode LOC per task (median)")
print(header)
print(sep)
for arm in arms:
row = [med_loc[arm][t] for t in task_ids]
print(f"{arm:<12}" + "".join(f"{v:>{col}}" for v in row) + f"{sum(row):>{col}}")
print(f"\nTime seconds per task (median)")
print(header)
print(sep)
for arm in arms:
row = [med_time[arm][t] for t in task_ids]
print(f"{arm:<12}" + "".join(f"{v:>{col}.1f}" for v in row) + f"{sum(row):>{col}.1f}")
print(f"\n{'=' * 60}")
print(" LOC vs baseline (median totals)")
print(f"{'=' * 60}")
base_total = sum(med_loc["baseline"][t] for t in task_ids)
for arm in ("caveman", "ponytail"):
arm_total = sum(med_loc[arm][t] for t in task_ids)
pct = (1 - arm_total / base_total) * 100 if base_total else 0
sign = "less" if pct >= 0 else "more"
print(f" {arm:10s}: {arm_total} LOC ({abs(pct):.0f}% {sign} than baseline)")
out = Path(__file__).parent / "benchmark-local-results.json"
out.write_text(json.dumps(results, indent=2), encoding="utf-8")
print(f"\nFull responses -> {out}")
def main():
parser = argparse.ArgumentParser(description="Ponytail local benchmark via Ollama")
parser.add_argument("--model", default="llama3.2", help="Ollama model name (default: llama3.2)")
parser.add_argument("--repeat", type=int, default=1, help="Runs per cell; median reported (default: 1)")
parser.add_argument("--ollama-url", default="http://localhost:11434", help="Ollama base URL")
args = parser.parse_args()
run(args.model, args.repeat, args.ollama_url)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
// Email under ponytail on Claude (ponytail's primary target), baseline vs ponytail.
const fs = require('fs'), path = require('path');
const { checkPy, pyBlock, TASKS } = require('./robustness-audit.js');
const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8');
const email = TASKS.find(t => t.name === 'email');
const N = Number(process.env.CE_N) || 40;
const MODELS = (process.env.CE_MODELS || 'claude-haiku-4-5-20251001,claude-sonnet-4-6,claude-opus-4-8').split(',');
const kv = Object.fromEntries(fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8')
.split(/\r?\n/).filter(l => l.includes('=') && !l.trim().startsWith('#'))
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }));
const KEY = kv.ANTHROPIC_API_KEY;
async function call(model, system, user) {
const body = { model, max_tokens: 1024, messages: [{ role: 'user', content: user }] };
if (system) body.system = system;
const r = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST',
headers: { 'x-api-key': KEY, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) return { err: r.status };
const j = await r.json();
return { text: (j.content || []).map(b => b.text || '').join('') };
}
(async () => {
console.log(`email, n=${N}\n`);
console.log('model baseline ponytail');
for (const model of MODELS) {
const rates = {};
for (const [arm, sys] of [['baseline', null], ['ponytail', skill]]) {
let pass = 0, err = 0;
for (let i = 0; i < N; i++) {
const r = await call(model, sys, email.prompt);
if (r.err) { err++; continue; }
if (checkPy(pyBlock(r.text), email)) pass++;
}
rates[arm] = `${pass}/${N - err}`;
}
console.log(`${model.padEnd(26)} ${rates.baseline.padEnd(10)} ${rates.ponytail}`);
}
})();
+22 -4
View File
@@ -13,7 +13,11 @@ const path = require('path');
// Extract fenced code blocks, tagged by language.
function extractBlocks(text) {
const matches = [...text.matchAll(/```(\w*)\n([\s\S]*?)```/g)];
text = String(text || '');
const matches = [...text.matchAll(/```(\w*)\r?\n([\s\S]*?)```/g)];
// ponytail: terse models often answer with bare, unfenced code. Treat the whole
// response as one block so the gate scores the code instead of reporting "no block".
if (matches.length === 0 && text.trim()) return [{ lang: '', code: text }];
return matches.map((m) => ({ lang: (m[1] || '').toLowerCase(), code: m[2] }));
}
@@ -38,6 +42,20 @@ function exec(cmd, opts = {}) {
}
}
// ponytail: probe once at load; macOS and many Linux images ship python3 only.
let pythonCmd;
function python() {
if (pythonCmd) return pythonCmd;
for (const cmd of ['python3', 'python']) {
if (exec(`${cmd} -c "import sys"`).ok) {
pythonCmd = cmd;
return pythonCmd;
}
}
pythonCmd = 'python3';
return pythonCmd;
}
// Write content to a temp file, return the path.
function tmpFile(ext, content) {
const p = path.join(os.tmpdir(), `ponytail-bench-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
@@ -100,14 +118,14 @@ if failures:
print("PASS")
`;
const f = tmpFile('.py', harness);
const result = exec(`python "${f}"`);
const result = exec(`${python()} "${f}"`);
fs.unlinkSync(f);
if (result.ok) return { pass: true, reason: 'Email validator passes all checks' };
return { pass: false, reason: result.stderr || 'Email validator failed' };
},
debounce(blocks) {
const code = blocks.find((b) => b.lang === 'javascript' || b.lang === 'js' || (!b.lang && b.code.includes('function')));
const code = blocks.find((b) => b.lang === 'javascript' || b.lang === 'js' || (!b.lang && (b.code.includes('function') || b.code.includes('=>'))));
if (!code) return { pass: false, reason: 'No JavaScript code block found' };
const harness = `
@@ -194,7 +212,7 @@ else:
sys.exit(1)
`;
const f = tmpFile('.py', harness);
const result = exec(`python "${f}"`);
const result = exec(`${python()} "${f}"`);
try { fs.unlinkSync(f); } catch (e) {}
try { fs.unlinkSync(csvPath); } catch (e) {}
if (result.ok) return { pass: true, reason: 'CSV sum produces correct result (351)' };
+26
View File
@@ -0,0 +1,26 @@
// Regression guard for the gate fixes (issue #65). Run: node correctness.test.js
// Needs python + node on PATH, same as correctness.js itself.
const assert = require('assert');
const check = require('./correctness.js');
const emailTask = { vars: { task: 'Write me a Python function that validates email addresses.' } };
const debounceTask = { vars: { task: 'Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay).' } };
const FENCED_EMAIL = '```python\nimport re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))\n```';
const UNFENCED_EMAIL = 'import re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))';
const WRONG_EMAIL = '```python\ndef validate_email(e):\n return True # accepts everything\n```';
const UNFENCED_ARROW_DEBOUNCE = 'const debounce = (fn, delay) => {\n let t;\n return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), delay); };\n};';
let pass = 0;
const cases = [
['fenced email still passes', check(FENCED_EMAIL, emailTask).pass, true],
['unfenced email now passes (bug #1 fix)', check(UNFENCED_EMAIL, emailTask).pass, true],
['broken email still fails', check(WRONG_EMAIL, emailTask).pass, false],
['unfenced arrow debounce passes (bug #1 + arrow-fn fix)', check(UNFENCED_ARROW_DEBOUNCE, debounceTask).pass, true],
];
for (const [name, got, want] of cases) {
assert.strictEqual(got, want, `FAILED: ${name} (got ${got}, want ${want})`);
console.log(`ok - ${name}`);
pass++;
}
console.log(`\n${pass}/${cases.length} passed`);
+3 -2
View File
@@ -1,9 +1,10 @@
// Deterministic code-size metric: non-blank, non-comment lines inside fenced code blocks.
// Deterministic code-size metric: non-blank, non-comment lines of code. Counts
// fenced blocks, or the whole response when the model emitted bare code unfenced.
// Recorded as the `code_loc` metric per arm (always passes; it is a measurement, not a gate).
module.exports = (output) => {
const text = String(output || '');
const blocks = [...text.matchAll(/```[a-zA-Z0-9_+-]*\n([\s\S]*?)```/g)].map((m) => m[1]);
const code = blocks.join('\n');
const code = blocks.length ? blocks.join('\n') : text;
const loc = code
.split('\n')
.map((l) => l.trim())
+39
View File
@@ -0,0 +1,39 @@
// Cross-model email rate at high n: is the parseaddr quirk gpt-5.4-mini-specific?
const fs = require('fs'), path = require('path');
const { checkPy, pyBlock, TASKS } = require('./robustness-audit.js');
const skill = fs.readFileSync(path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8');
const email = TASKS.find(t => t.name === 'email');
const N = Number(process.env.ME_N) || 100;
const MODELS = (process.env.ME_MODELS || 'gpt-4.1-mini,gpt-5.4-mini').split(',');
const kv = Object.fromEntries(fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8')
.split(/\r?\n/).filter(l => l.includes('=') && !l.trim().startsWith('#'))
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }));
const KEY = kv.OPENAI_API_KEY;
async function call(model, system, user) {
const body = { model, max_completion_tokens: 4096,
messages: system ? [{ role: 'system', content: system }, { role: 'user', content: user }] : [{ role: 'user', content: user }] };
const r = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) return { err: r.status };
return { text: (await r.json()).choices?.[0]?.message?.content || '' };
}
(async () => {
console.log(`email, n=${N}\n`);
console.log('model baseline ponytail');
for (const model of MODELS) {
const rates = {};
for (const [arm, sys] of [['baseline', null], ['ponytail', skill]]) {
let pass = 0, err = 0;
for (let i = 0; i < N; i++) {
const r = await call(model, sys, email.prompt);
if (r.err) { err++; continue; }
if (checkPy(pyBlock(r.text), email)) pass++;
}
rates[arm] = `${pass}/${N - err}`;
}
console.log(`${model.padEnd(15)} ${rates.baseline.padEnd(10)} ${rates.ponytail}`);
}
})();
+32
View File
@@ -0,0 +1,32 @@
# Reproduces Pyseph's issue-65 setup: baseline vs ponytail, gpt-4.1-mini + gpt-5.4-mini.
# Reuses the repo's arms + loc/correctness gates. Needs OPENAI_API_KEY in ../.env.
# npx promptfoo@latest eval -c benchmarks/promptfooconfig.gpt.yaml --repeat N
description: "Ponytail vs baseline on GPT-mini models (issue #65 repro). LOC + correctness gate."
providers:
- id: openai:gpt-4.1-mini
config: { max_tokens: 4096, temperature: 1 }
- id: openai:gpt-5.4-mini
config: { max_completion_tokens: 4096 }
prompts:
- id: file://arms/baseline.js
label: baseline (no skill)
- id: file://arms/ponytail.js
label: ponytail
defaultTest:
assert:
- type: javascript
value: file://loc.js
metric: code_loc
- type: javascript
value: file://correctness.js
metric: correct
tests:
- vars: { task: "Write me a Python function that validates email addresses." }
- vars: { task: "Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay) returns a debounced version of fn that delays calling it until delay ms after the last call." }
- vars: { task: "Write Python code that reads sales.csv and sums the 'amount' column." }
- vars: { task: "Build me a countdown timer component in React that counts down from a given number of seconds." }
- vars: { task: "Add rate limiting to my FastAPI endpoint so users can't spam it." }
+1 -1
View File
@@ -35,7 +35,7 @@ defaultTest:
tests:
- vars: { task: "Write me a Python function that validates email addresses." }
- vars: { task: "Add debounce to a search input in vanilla JavaScript. It currently fires an API call on every keystroke." }
- vars: { task: "Write a reusable debounce function in vanilla JavaScript: debounce(fn, delay) returns a debounced version of fn that delays calling it until delay ms after the last call." }
- vars: { task: "Write Python code that reads sales.csv and sums the 'amount' column." }
- vars: { task: "Build me a countdown timer component in React that counts down from a given number of seconds." }
- vars: { task: "Add rate limiting to my FastAPI endpoint so users can't spam it." }
@@ -0,0 +1,76 @@
# Local model benchmark: llama3.2 via Ollama — 2026-06-15
Same 5 tasks as the Claude benchmark, same three arms (baseline / caveman / ponytail),
run against a local **llama3.2:latest** (3.2B, Q4_K_M) via Ollama on a Windows 11 machine.
Tooling: `benchmarks/benchmark-local.py` (no promptfoo needed).
> **Updated 2026-06-15:** the LOC counter now counts bare, unfenced code. It
> previously counted only fenced code blocks and scored everything else as 0,
> which silently deflated any arm whose output happened to skip the fences (small
> models do this often). Numbers below use the corrected counter at n=5 median.
> Absolute times reflect this machine (GPU-accelerated); compare arms within a
> run, not against an earlier CPU-bound machine.
## Results (n=5, median)
**Code LOC**
| arm | email | debounce | csv-sum | countdown | rate-limit | **TOTAL** |
|---|--:|--:|--:|--:|--:|--:|
| baseline | 16 | 18 | 22 | 37 | 16 | **109** |
| caveman | 16 | 21 | 18 | 46 | 32 | **133** |
| ponytail | 17 | 22 | 18 | 52 | 28 | **137** |
**Time (seconds)**
| arm | email | debounce | csv-sum | countdown | rate-limit | **TOTAL** |
|---|--:|--:|--:|--:|--:|--:|
| baseline | 3.1 | 3.7 | 3.6 | 4.2 | 4.8 | **19.4** |
| caveman | 4.1 | 4.2 | 3.6 | 4.4 | 4.8 | **21.1** |
| ponytail | 4.1 | 4.2 | 3.8 | 4.8 | 4.9 | **21.8** |
## Key findings
**On llama3.2 the LOC effect is inside the noise floor.** At temperature 0.7 the
per-run totals swing hard: across the five runs, ponytail landed anywhere from
17% *below* baseline to 50% *above* it. The n=5 median came out +26%; a separate
n=3 median came out 17%. The aggregate itself flips sign depending on the
sample, and the countdown task alone ranged 19 to 74 LOC on baseline. There is no
stable LOC reduction to report.
**Ponytail does not transfer to llama3.2.** The 80-94% LOC reduction seen on
Claude is simply absent: the signal is lost in run-to-run variance. The one
consistent effect is on time, and it goes the wrong way: ponytail is ~10-15%
*slower* than baseline (more system-prompt tokens to process), never the 3-6x
speedup seen on Claude.
**Why:** ponytail is a prompt-engineering skill calibrated on Claude models,
which are trained to follow detailed system instructions. A 3.2B quantised model
absorbs the rules only partially and adds prose justifying its choices, paying
the instruction-following cost without reliably converting it into less code.
## Reproduce
Install Ollama and pull a model, then run from the repo root:
```bash
ollama pull llama3.2
python benchmarks/benchmark-local.py --model llama3.2 --repeat 5
```
At this model size the LOC signal is noisy; raise `--repeat` (or lower the
sampling temperature in the script) before reading anything into the totals.
Optional flags:
```
--repeat N Runs per cell; median is reported (default: 1)
--ollama-url URL Ollama base URL (default: http://localhost:11434)
```
## Takeaway
The benchmark claims in the README are accurate for the models tested (Haiku,
Sonnet, Opus). For local/small models, expect the gains to shrink into the noise
until instruction-following reaches a threshold comparable to Claude Haiku or
better.
@@ -0,0 +1,107 @@
# Correctness under Ponytail: gate fixes + GPT-mini reproduction (2026-06-16)
Context: [issue #65](https://github.com/DietrichGebert/ponytail/issues/65) asked whether
Ponytail degrades model performance. A community run (Pyseph) reported a large correctness
drop on `gpt-4.1-mini` (10/15 with Ponytail vs 15/15 without) and a small one on
`gpt-5.4-mini` (14/15 vs 15/15).
Investigating that, the correctness gate itself turned out to be the main culprit. This
writeup documents the gate bugs, the fixes, and a clean reproduction of Pyseph's exact
model setup.
## TL;DR
- The `correct` gate had two bugs that **under-reported correctness for terse models** — it
could not read unfenced code, and the debounce task tested for a deliverable the prompt
never asked for.
- After fixing the gate, on a clean `n=20` run of Pyseph's exact models, the large drop
**does not reproduce**: `gpt-4.1-mini` is 100% with *and* without Ponytail.
- Ponytail roughly **halves** median code size, the original headline claim, with no
meaningful correctness cost on instruction-following models.
- One genuine, small Ponytail defect surfaced and is reported honestly below.
## The gate bugs
1. **Unfenced code was scored as "no code blocks."** `extractBlocks()` only matched
```` ```fenced``` ```` blocks. Models that reply with bare code (more common under
Ponytail's terse style, and frequent on `gpt-5.4-mini`) scored an automatic fail even
when the code was correct. This alone accounted for 41 of 74 failures in the first GPT run.
2. **The debounce task tested the wrong deliverable.** The prompt said *"add debounce to a
search input"* but the check expected a reusable `debounce(fn, delay)` utility it could
call. A correct inline answer (`input.addEventListener(... clearTimeout ...)`) failed with
`searchInput is not defined`. This accounted for 31 of 74 failures, and it penalized the
literal, minimal answer while rewarding code that over-built a utility nobody asked for.
Both are fixed: `extractBlocks()` now falls back to treating the whole response as one code
block when no fence is present (and tolerates CRLF), and the debounce task now asks for the
reusable `debounce(fn, delay)` function the check actually verifies.
## Method
Two arms (baseline = no skill, ponytail), Pyseph's two models, the five repo tasks, `n=20`
per cell, run serially (`--max-concurrency 1`) so transient quota 429s never reduced the
denominators. Code is executed where possible (email, debounce, CSV); React/FastAPI are
structural checks (see the README caveat). Claude numbers are a free re-score of the
committed `output-10x.json` responses through the fixed gate (`n=10`, 4 tasks — the saved
debounce responses predate the prompt fix and are excluded).
## Results
### GPT-mini (clean `n=20`, 0 errors, full denominators)
| model | baseline | ponytail | median LOC (base → pony) |
|---|--:|--:|--:|
| gpt-4.1-mini | 100/100 | 100/100 | 15 → 7 |
| gpt-5.4-mini | 100/100 | 98/100 | 16 → 7 |
Pyseph's reported `gpt-4.1-mini` drop (10/15 ≈ 67%) does not reproduce — it scores 100% here.
The difference is the gate fixes; the original numbers were measuring unfenced code and the
debounce deliverable mismatch, not model degradation.
### Claude (fixed gate, re-score of committed responses, `n=10`, 4 tasks)
| model | baseline | ponytail |
|---|--:|--:|
| claude-haiku-4-5 | 38/40 (95%) | 40/40 (100%) |
| claude-opus-4-8 | 40/40 (100%) | 40/40 (100%) |
| claude-sonnet-4-6 | 28/40 (70%) | 40/40 (100%) |
On instruction-following models Ponytail ties or slightly *beats* baseline. The low
`sonnet` baseline number is itself an over-engineering failure: the unconstrained validator
returns a rich `{is_valid, message}` dict instead of a bool, so `if validate_email(addr)` is
always truthy and accepts every address — a real bug Ponytail's `return bool(...)` avoids.
## The one real Ponytail defect
On `gpt-5.4-mini`, 2 of 20 Ponytail email runs failed because the model reached for the
laziest stdlib option:
```python
from email.utils import parseaddr
def is_valid_email(email):
_, addr = parseaddr(email)
return addr == email and "@" in addr # accepts "@missing-local.com"
```
`parseaddr` does not require a local part, so `"@missing-local.com"` is accepted. This is a
genuine (if minor) cost of pushing toward one-liners: occasionally the chosen stdlib helper
has an edge-case hole. The other 18 runs used a regex and passed.
## Reproduce
```bash
# GPT arms (needs OPENAI_API_KEY in ../.env)
cd benchmarks
npx promptfoo@latest eval -c promptfooconfig.gpt.yaml --env-file ../.env --repeat 20 --max-concurrency 1
# Claude re-score of committed responses through the fixed gate
node -e 'const c=require("./correctness.js"),d=require("./output-10x.json");/* score d.results.results through c */'
```
## Takeaway
The "Ponytail hurts correctness" reports trace to a benchmark that could not read terse
output, not to the skill. With the gate fixed, the LOC win holds and correctness is flat on
capable models. The honest caveats remain: the effect is model-dependent (small/local models
follow the ladder poorly — see the llama3.2 writeup), and chasing the shortest answer can
occasionally pick a stdlib helper with an edge-case gap.
@@ -0,0 +1,129 @@
# Robustness audit: does ponytail degrade weak models? (2026-06-16)
Follow-up to [issue #65](https://github.com/DietrichGebert/ponytail/issues/65). After fixing
the correctness-gate bugs, the open question was the real one: does Ponytail's push toward
the shortest solution make weak models produce *wrong* code on edge cases? This audit
answers it directly, with a deliberately hostile test set and high sample counts.
## TL;DR
- Across **12 classic edge-case traps** (off-by-one, n=0, leap-century, subtractive Roman,
deep nesting, …) on **two weak models** (`gpt-4.1-mini`, `gpt-5.4-mini`), Ponytail holds
**baseline parity** — it does not produce more wrong answers than the unconstrained model.
- The **one** measured soft spot is email validation, and it is **provider-specific**.
OpenAI models, at every size, sometimes reach for `email.utils.parseaddr` (a parser, not a
validator) under "stdlib-first" pressure and accept `"@missing-local.com"`. On Claude,
ponytail's target platform, email is **100%** (haiku/sonnet/opus, n=40 each).
- The slip is **not fixable by skill text**: 8 distinct SKILL.md edits (including an n=100
A/B, 96% → 95%) all scored ≤ the current skill, several worse, all bloating LOC. Counter-
instructions make small models overthink and fail *more*. Nothing was shipped — adding
skill text that doesn't move the number is exactly the cargo-cult Ponytail exists to avoid.
## Method
`baseline` (no skill) vs `ponytail` (full SKILL.md), single-shot, default params,
`gpt-4.1-mini` and `gpt-5.4-mini`. Each task runs generated code against edge-case
assertions. Every check is **self-verified**: a known-correct and a known-lazy-wrong
reference must pass/fail respectively before any model output is scored
(`node robustness-audit.js --selftest`, 16/16). Runs were serial to avoid quota 429s
shrinking denominators.
## Edge-case traps (n=20/cell)
All 12 algorithmic tasks: `baseline 20/20 == ponytail 20/20` on **both** models. Examples
of the traps (the lazy version passes the common case, fails the edge):
| task | the trap a lazy impl misses |
|---|---|
| is_prime | n = 0, 1, negatives |
| factorial / fibonacci | n = 0 |
| binary_search | empty list, target at the last index (off-by-one) |
| is_leap_year / days_in_month | 1900 not leap, 2000 leap (century rule) |
| int_to_roman | subtractive forms (4=IV, 9=IX, 40=XL) |
| flatten | nesting deeper than one level |
| clamp | value already in range |
| chunk | trailing remainder |
The only sub-20 cell in the first run was `gpt-5.4-mini` flatten at 19/20 — a single
stochastic miss that **did not reproduce**: 50/50 at n=50. (`clamp` showed 19/19, i.e. one
API error, not a wrong answer.)
## Validators: the email slip is provider-specific
The one place ponytail measurably affects correctness is **email validation**, via the
parse ≠ validate trap: under "stdlib-first" pressure a model reaches for
`email.utils.parseaddr` — a *parser* that accepts malformed input like `@missing-local.com`
— instead of writing an explicit check. The split is by **provider**, not model size.
**OpenAI (email, baseline vs ponytail, n=50100):**
| model | baseline | ponytail |
|---|--:|--:|
| gpt-4.1-mini | 100% | 98% |
| gpt-4.1 | 100% | 79% |
| gpt-5.4-mini | ~100% | ~92% |
| gpt-5.4 | 100% | 98% |
| gpt-5.5 | 98% | 94% |
**Claude (email, baseline vs ponytail, n=40):**
| model | baseline | ponytail |
|---|--:|--:|
| claude-haiku-4-5 | 35/40 | **40/40** |
| claude-sonnet-4-6 | 0/40 * | **40/40** |
| claude-opus-4-8 | 39/40 | **40/40** |
Every OpenAI model slips regardless of size (gpt-4.1 full is the worst). Every Claude model
is **100%** under ponytail.
\* The Sonnet baseline `0/40` is a return-type artifact, not a logic failure, and should not
be read as "Sonnet cannot validate email." Unconstrained Sonnet over-engineers the validator
into a `dict` (`{is_valid, message}`) instead of a bool. The test calls the function as a
bool, and a non-empty dict is always truthy, so it "accepts" every address and scores 0.
Read dict-aware (via `is_valid`), its logic is about 75% correct (9/12). The honest point is
narrow: ponytail writes the plain correct bool the task implies, while the unconstrained
model over-builds the interface and trips a naive `if validate(x)` caller. `url`,
`creditcard`, and `ipv4` hold at ~100% under ponytail on both providers, because their lazy
stdlib choice (`ipaddress`, Luhn, scheme checks) is already strict. Only email's obvious
stdlib tool is a parser.
## The fix that wasn't
SKILL.md already says "never simplify away input validation" and "pick the stdlib option
correct on edge cases." We tried hard to push the OpenAI rate to 100% by editing the skill —
**8 distinct edits** across counter-pressure wording, a check-mandate, explicit-over-delegate,
a few-shot example, combinations, and three placements. Every one scored ≤ the current skill;
several were far worse (one cratered to 78%); all bloated median LOC. The definitive n=100
A/B of the most promising edit:
```
OLD skill: 96/100 (96.0%)
NEW skill: 95/100 (95.0%) -> within noise, no reliable effect
```
Counter-instructions backfire: piling validation rules onto the skill makes models overthink
and produce *more* broken validators, not fewer. The reflex to reach for `parseaddr` lives in
the OpenAI models' training, and no skill wording reliably overrides it — so nothing was
shipped. Adding skill text that doesn't work is the cargo-cult Ponytail exists to prevent.
## Conclusion
"Ponytail degrades model performance" is not supported. Across 12 edge-case traps, ponytail
holds baseline parity. On validation it is **100% on every Claude model**, which is its
target platform. The only blemish is an email-validator slip on OpenAI models (a
cross-provider `parseaddr` reflex, present at every size), documented here and not fixable by
skill text. The LOC win (about half the code) comes with no correctness tax on Claude.
## Reproduce
```bash
cd benchmarks
node robustness-audit.js --selftest # verify all 16 instruments (no API)
node robustness-audit.js # 16-task audit, gpt-5.4-mini, n=20
AUDIT_MODEL=gpt-4.1-mini node robustness-audit.js
# email cross-provider (the slip)
ME_MODELS="gpt-4.1,gpt-5.4,gpt-5.5" ME_N=50 node model-email.js # OpenAI (OPENAI_API_KEY)
node claude-email.js # Claude (ANTHROPIC_API_KEY)
```
`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` read from `../.env`.
+194
View File
@@ -0,0 +1,194 @@
// Robustness audit (issue #65 follow-up): find where ponytail actually breaks on a
// weak model. 12 tasks with classic edge-case traps. Each has a known-good and a
// known-lazy-wrong reference so the instrument is verified before any API spend.
// node robustness-audit.js --selftest # no API: prove every check is correct
// node robustness-audit.js # baseline vs ponytail, gpt-5.4-mini, n=20
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const N = Number(process.env.AUDIT_N) || 20;
const MODEL = process.env.AUDIT_MODEL || 'gpt-5.4-mini';
const ROOT = path.join(__dirname, '..');
let kv = {};
try {
kv = Object.fromEntries(fs.readFileSync(path.join(ROOT, '.env'), 'utf8')
.split(/\r?\n/).filter(l => l.includes('=') && !l.trim().startsWith('#'))
.map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }));
} catch (_) { /* no .env — fine for --selftest */ }
const KEY = process.env.OPENAI_API_KEY || kv.OPENAI_API_KEY;
const SKILL = fs.readFileSync(path.join(ROOT, 'skills', 'ponytail', 'SKILL.md'), 'utf8');
// task = { name, prompt, names, arity, cases: [[argsArray, expected], ...], good, bad }
const TASKS = [
{ name: 'is_prime', arity: 1, names: ['is_prime', 'isprime', 'prime'],
prompt: 'Write a Python function is_prime(n) that returns True if n is prime, else False.',
cases: [[[2], true], [[1], false], [[0], false], [[-7], false], [[17], true], [[15], false], [[97], true]],
good: 'def is_prime(n):\n if n < 2: return False\n for i in range(2, int(n**0.5)+1):\n if n % i == 0: return False\n return True',
bad: 'def is_prime(n):\n for i in range(2, n):\n if n % i == 0: return False\n return True' },
{ name: 'factorial', arity: 1, names: ['factorial', 'fact'],
prompt: 'Write a Python function factorial(n).',
cases: [[[0], 1], [[1], 1], [[5], 120], [[6], 720]],
good: 'def factorial(n):\n r = 1\n for i in range(2, n+1): r *= i\n return r',
bad: 'def factorial(n):\n r = 1\n for i in range(1, n): r *= i\n return r' },
{ name: 'fibonacci', arity: 1, names: ['fibonacci', 'fib'],
prompt: 'Write fibonacci(n) returning the nth Fibonacci number, with fib(0)=0 and fib(1)=1.',
cases: [[[0], 0], [[1], 1], [[2], 1], [[7], 13], [[10], 55]],
good: 'def fibonacci(n):\n a, b = 0, 1\n for _ in range(n): a, b = b, a+b\n return a',
bad: 'def fibonacci(n):\n a, b = 1, 1\n for _ in range(n): a, b = b, a+b\n return a' },
{ name: 'gcd', arity: 2, names: ['gcd'],
prompt: 'Write gcd(a, b) returning the greatest common divisor.',
cases: [[[12, 8], 4], [[5, 0], 5], [[0, 5], 5], [[17, 5], 1], [[100, 75], 25]],
good: 'def gcd(a, b):\n while b: a, b = b, a % b\n return a',
bad: 'def gcd(a, b):\n for i in range(min(a, b), 0, -1):\n if a % i == 0 and b % i == 0: return i' },
{ name: 'binary_search', arity: 2, names: ['binary_search', 'bsearch', 'search'],
prompt: 'Write binary_search(arr, target) returning the index of target in the sorted list arr, or -1 if absent.',
cases: [[[[1, 2, 3, 4, 5], 3], 2], [[[1, 2, 3, 4, 5], 1], 0], [[[1, 2, 3, 4, 5], 5], 4], [[[1, 2, 3, 4, 5], 6], -1], [[[], 1], -1], [[[1], 1], 0]],
good: 'def binary_search(arr, target):\n lo, hi = 0, len(arr)-1\n while lo <= hi:\n m = (lo+hi)//2\n if arr[m] == target: return m\n elif arr[m] < target: lo = m+1\n else: hi = m-1\n return -1',
bad: 'def binary_search(arr, target):\n lo, hi = 0, len(arr)-1\n while lo < hi:\n m = (lo+hi)//2\n if arr[m] == target: return m\n elif arr[m] < target: lo = m+1\n else: hi = m-1\n return -1' },
{ name: 'is_leap_year', arity: 1, names: ['is_leap_year', 'is_leap', 'leap'],
prompt: 'Write is_leap_year(year) returning True if it is a leap year.',
cases: [[[2000], true], [[1900], false], [[2020], true], [[2021], false], [[2400], true], [[2100], false]],
good: 'def is_leap_year(y):\n return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)',
bad: 'def is_leap_year(y):\n return y % 4 == 0' },
{ name: 'days_in_month', arity: 2, names: ['days_in_month'],
prompt: 'Write days_in_month(year, month) returning the number of days in that month.',
cases: [[[2020, 2], 29], [[2021, 2], 28], [[1900, 2], 28], [[2000, 2], 29], [[2021, 4], 30], [[2021, 1], 31], [[2021, 12], 31]],
good: 'import calendar\ndef days_in_month(year, month):\n return calendar.monthrange(year, month)[1]',
bad: 'def days_in_month(year, month):\n return [31,28,31,30,31,30,31,31,30,31,30,31][month-1]' },
{ name: 'int_to_roman', arity: 1, names: ['int_to_roman', 'to_roman', 'roman'],
prompt: 'Write int_to_roman(n) converting an integer (1-3999) to its Roman numeral string.',
cases: [[[4], 'IV'], [[9], 'IX'], [[58], 'LVIII'], [[1994], 'MCMXCIV'], [[40], 'XL'], [[3], 'III']],
good: "def int_to_roman(n):\n vals=[(1000,'M'),(900,'CM'),(500,'D'),(400,'CD'),(100,'C'),(90,'XC'),(50,'L'),(40,'XL'),(10,'X'),(9,'IX'),(5,'V'),(4,'IV'),(1,'I')]\n r=''\n for v,s in vals:\n while n>=v: r+=s; n-=v\n return r",
bad: "def int_to_roman(n):\n vals=[(1000,'M'),(500,'D'),(100,'C'),(50,'L'),(10,'X'),(5,'V'),(1,'I')]\n r=''\n for v,s in vals:\n while n>=v: r+=s; n-=v\n return r" },
{ name: 'flatten', arity: 1, names: ['flatten'],
prompt: 'Write flatten(lst) that fully flattens an arbitrarily nested list of integers into a flat list.',
cases: [[[[1, [2, [3, 4]], 5]], [1, 2, 3, 4, 5]], [[[]], []], [[[1, 2, 3]], [1, 2, 3]], [[[1, [2], [[3]]]], [1, 2, 3]]],
good: 'def flatten(lst):\n out = []\n for x in lst:\n if isinstance(x, list): out.extend(flatten(x))\n else: out.append(x)\n return out',
bad: 'def flatten(lst):\n return [x for s in lst for x in (s if isinstance(s, list) else [s])]' },
{ name: 'chunk', arity: 2, names: ['chunk'],
prompt: 'Write chunk(lst, size) splitting lst into consecutive sublists of length size (the last may be shorter).',
cases: [[[[1, 2, 3, 4, 5], 2], [[1, 2], [3, 4], [5]]], [[[1, 2, 3, 4], 2], [[1, 2], [3, 4]]], [[[], 3], []], [[[1], 5], [[1]]]],
good: 'def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst), size)]',
bad: 'def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst)-size+1, size)]' },
{ name: 'clamp', arity: 3, names: ['clamp'],
prompt: 'Write clamp(value, low, high) returning value bounded to the range [low, high].',
cases: [[[5, 0, 10], 5], [[-1, 0, 10], 0], [[15, 0, 10], 10], [[0, 0, 10], 0], [[10, 0, 10], 10]],
good: 'def clamp(value, low, high):\n return max(low, min(value, high))',
bad: 'def clamp(value, low, high):\n if value < low: return low\n if value > high: return high' },
{ name: 'is_palindrome', arity: 1, names: ['is_palindrome', 'palindrome', 'is_pal'],
prompt: 'Write is_palindrome(s) returning True if s is a palindrome, ignoring case, spaces, and punctuation.',
cases: [[['racecar'], true], [['A man, a plan, a canal: Panama'], true], [['hello'], false], [[''], true], [["No 'x' in Nixon"], true], [['ab'], false]],
good: "def is_palindrome(s):\n c = [ch.lower() for ch in s if ch.isalnum()]\n return c == c[::-1]",
bad: 'def is_palindrome(s):\n return s == s[::-1]' },
// Validators: the parse != validate trap. email is ponytail's one measured soft spot
// on gpt-5.4-mini (~4-5%, parseaddr); the rest hold parity. See results writeup.
{ name: 'email', arity: 1, names: ['validate_email', 'is_valid_email', 'email_validator', 'is_valid', 'validate'],
prompt: 'Write me a Python function that validates email addresses.',
cases: [[['user@example.com'], true], [['a@b.co'], true], [['no-at-sign'], false], [[''], false], [['@missing-local.com'], false]],
good: 'import re\ndef validate_email(e):\n return bool(re.match(r"^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", e))',
bad: 'from email.utils import parseaddr\ndef validate_email(e):\n _, a = parseaddr(e)\n return a == e and "@" in a' },
{ name: 'url', arity: 1, names: ['validate_url', 'is_valid_url', 'is_url', 'validate', 'is_valid'],
prompt: 'Write a Python function that validates whether a string is a valid HTTP or HTTPS URL.',
cases: [[['https://example.com'], true], [['http://a.b/c'], true], [['https://x.io/p?q=1'], true], [['garbage'], false], [[''], false], [['example.com'], false], [['ftp://example.com'], false], [['http://'], false]],
good: 'from urllib.parse import urlparse\ndef validate_url(u):\n p = urlparse(u)\n return p.scheme in ("http","https") and bool(p.netloc)',
bad: 'from urllib.parse import urlparse\ndef validate_url(u):\n return bool(urlparse(u))' },
{ name: 'creditcard', arity: 1, names: ['validate_credit_card', 'is_valid_card', 'validate_card', 'luhn', 'validate', 'is_valid'],
prompt: 'Write a Python function that validates a credit card number.',
cases: [[['4242424242424242'], true], [['4012888888881881'], true], [['4242424242424241'], false], [['12345'], false], [['abcd'], false]],
good: 'def validate_credit_card(n):\n d=[int(c) for c in str(n) if c.isdigit()]\n if len(d)<13: return False\n s=0\n for i,x in enumerate(reversed(d)):\n if i%2==1:\n x*=2\n if x>9: x-=9\n s+=x\n return s%10==0',
bad: "def validate_credit_card(n):\n s=str(n).replace(' ','')\n return s.isdigit() and len(s)==16" },
{ name: 'ipv4', arity: 1, names: ['validate_ipv4', 'is_valid_ip', 'is_ipv4', 'validate_ip', 'validate', 'is_valid'],
prompt: 'Write a Python function that validates an IPv4 address.',
cases: [[['192.168.1.1'], true], [['0.0.0.0'], true], [['255.255.255.255'], true], [['999.999.999.999'], false], [['256.1.1.1'], false], [['1.2.3'], false], [['abc'], false]],
good: 'import ipaddress\ndef validate_ipv4(s):\n try:\n ipaddress.IPv4Address(s); return True\n except Exception: return False',
bad: "import re\ndef validate_ipv4(s):\n return bool(re.match(r'^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$', s))" },
];
function pyBlock(text) {
const m = [...String(text || '').matchAll(/```(\w*)\r?\n([\s\S]*?)```/g)];
if (!m.length) return text || '';
const py = m.find(x => /py/.test(x[1]));
return (py || m[0])[2];
}
function checkPy(code, task) {
const harness = `import sys, json, inspect
${code}
TARGET = ${task.arity}
names = json.loads(r'''${JSON.stringify(task.names)}''')
fn = None
for nm in names:
if nm in dir() and callable(eval(nm)): fn = eval(nm); break
if fn is None:
for nm, obj in list(globals().items()):
if callable(obj) and not nm.startswith('_') and not inspect.isclass(obj):
try:
if len(inspect.signature(obj).parameters) == TARGET: fn = obj; break
except (ValueError, TypeError): pass
if fn is None: print('NOFN'); sys.exit(1)
cases = json.loads(r'''${JSON.stringify(task.cases)}''')
for args, expected in cases:
try: r = fn(*args)
except Exception as e: print('EXC', args, e); sys.exit(1)
if r != expected: print('MISMATCH', args, '->', r, 'want', expected); sys.exit(1)
print('PASS')`;
const f = path.join(os.tmpdir(), `audit-${process.pid}-${Math.random().toString(36).slice(2)}.py`);
fs.writeFileSync(f, harness);
try { execSync(`python3 "${f}"`, { timeout: 10000, encoding: 'utf8', stdio: 'pipe' }); return true; }
catch (e) { return false; }
finally { try { fs.unlinkSync(f); } catch (_) {} }
}
async function call(system, user) {
const body = { model: MODEL, max_completion_tokens: 4096,
messages: system ? [{ role: 'system', content: system }, { role: 'user', content: user }] : [{ role: 'user', content: user }] };
const r = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) return { err: r.status };
const j = await r.json();
return { text: j.choices?.[0]?.message?.content || '' };
}
module.exports = { checkPy, pyBlock, call, TASKS, SKILL };
if (require.main !== module) return;
if (process.argv.includes('--selftest')) {
let ok = 0, bad = 0;
for (const t of TASKS) {
const g = checkPy(t.good, t), b = checkPy(t.bad, t);
const pass = g === true && b === false;
console.log(`${pass ? 'ok ' : 'XX '} ${t.name.padEnd(16)} good=${g} bad=${b}`);
pass ? ok++ : bad++;
}
console.log(`\nself-test: ${ok}/${TASKS.length} instruments valid${bad ? `${bad} BROKEN` : ''}`);
process.exit(bad ? 1 : 0);
}
(async () => {
const arms = { baseline: null, ponytail: SKILL };
const grid = {};
for (const t of TASKS) {
grid[t.name] = {};
for (const arm of Object.keys(arms)) {
let pass = 0, err = 0;
for (let i = 0; i < N; i++) {
const res = await call(arms[arm], t.prompt);
if (res.err) { err++; continue; }
if (checkPy(pyBlock(res.text), t)) pass++;
}
grid[t.name][arm] = { pass, n: N - err };
}
const b = grid[t.name].baseline, p = grid[t.name].ponytail;
const flag = p.pass < b.pass ? ' <-- PONYTAIL REGRESSION' : (p.pass < p.n ? ' (both imperfect)' : '');
console.log(`${t.name.padEnd(16)} baseline ${b.pass}/${b.n} ponytail ${p.pass}/${p.n}${flag}`);
}
console.log('\n=== ponytail holes (ponytail < baseline) ===');
let any = false;
for (const t of TASKS) {
const b = grid[t.name].baseline, p = grid[t.name].ponytail;
if (p.pass < b.pass) { console.log(` ${t.name}: ${b.pass} -> ${p.pass}`); any = true; }
}
if (!any) console.log(' none');
})();
+2
View File
@@ -0,0 +1,2 @@
description = "Quick reference for ponytail levels, skills, and commands"
prompt = "Show the ponytail quick reference. One shot, change nothing: do not switch mode, write flag files, or persist anything. Levels: /ponytail lite (build what's asked, name the lazier alternative in one line), /ponytail (full, the default ladder: YAGNI then stdlib then native then one line then minimum), /ponytail ultra (deletion before addition, challenges the requirement before building). Commands: /ponytail-review (over-engineering review of the current changes), /ponytail-audit (whole-repo over-engineering audit), /ponytail-debt (harvest ponytail: comments into a tracked ledger), /ponytail-help (this card). Deactivate with 'stop ponytail', 'normal mode', or /ponytail off; resume anytime with /ponytail. Default mode is full; change it with the PONYTAIL_DEFAULT_MODE environment variable (off|lite|full|ultra) or a config file at ~/.config/ponytail/config.json (Windows: %APPDATA%\\ponytail\\config.json) with {\"defaultMode\": \"lite\"}. Resolution order: env var, then config file, then full."
+1 -1
View File
@@ -17,7 +17,7 @@ to load in a given agent.
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
| Cline | `.clinerules/ponytail.md` | Project rule. |
| GitHub Copilot | `.github/copilot-instructions.md` | Repository instruction file. |
| GitHub Copilot CLI | `AGENTS.md`, `.github/copilot-instructions.md`, `~/.copilot/copilot-instructions.md` | Reads custom instructions: per-project from `AGENTS.md` or `.github/copilot-instructions.md`, or globally from `~/.copilot/copilot-instructions.md`. Instruction-tier (no `/ponytail` levels or hooks). |
| GitHub Copilot CLI | `.github/plugin/`, `AGENTS.md`, `.github/copilot-instructions.md`, `~/.copilot/copilot-instructions.md` | Plugin-supported (`copilot plugin marketplace add DietrichGebert/ponytail` + `copilot plugin install ponytail@ponytail`). Fallback instruction mode remains: per-project from `AGENTS.md` or `.github/copilot-instructions.md`, or globally from `~/.copilot/copilot-instructions.md` (instruction-tier, no `/ponytail` levels or hooks). |
| Antigravity | `AGENTS.md` | Reads `AGENTS.md` at the repo root as always-on rules (like `.cursorrules`/`CLAUDE.md`); `.agents/rules/` also works for workspace rules. Instruction-tier. |
| VS Code + Codex extension | `AGENTS.md` | The Codex extension reads `AGENTS.md` (repo root, or `~/.codex/AGENTS.md` globally). Instruction-tier; the full Codex plugin row above adds `/ponytail` levels and hooks. |
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.4.0",
"version": "4.7.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"contextFileName": "AGENTS.md"
}
+21
View File
@@ -0,0 +1,21 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"bash": "node \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"powershell": "node \"${PLUGIN_ROOT}\\hooks\\ponytail-activate.js\"",
"timeoutSec": 5
}
],
"userPromptSubmitted": [
{
"type": "command",
"bash": "node \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"powershell": "node \"${PLUGIN_ROOT}\\hooks\\ponytail-mode-tracker.js\"",
"timeoutSec": 5
}
]
}
}
+4 -4
View File
@@ -6,8 +6,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"commandWindows": "node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\"",
"command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\" || exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
}
@@ -19,8 +19,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"commandWindows": "node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\"",
"command": "command -v node >/dev/null 2>&1 && node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\" || exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."
}
+18 -6
View File
@@ -2,10 +2,15 @@ const fs = require('fs');
const path = require('path');
const { getClaudeDir } = require('./ponytail-config');
const isCodex = Boolean(process.env.PLUGIN_DATA);
const statePath = isCodex
? path.join(process.env.PLUGIN_DATA, '.ponytail-active')
: path.join(getClaudeDir(), '.ponytail-active');
const STATE_FILE = '.ponytail-active';
const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA);
const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA);
let stateDir = getClaudeDir();
if (isCodex) stateDir = process.env.PLUGIN_DATA;
if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA;
const statePath = path.join(stateDir, STATE_FILE);
function setMode(mode) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
@@ -17,10 +22,13 @@ function clearMode() {
}
function writeHookOutput(event, mode, context = '') {
if (!isCodex) {
process.stdout.write(context);
if (isCopilot) {
// Copilot reads additionalContext on SessionStart; ignores output elsewhere.
process.stdout.write(JSON.stringify(
event === 'SessionStart' && context ? { additionalContext: context } : {}));
return;
}
if (isCodex) {
const output = { systemMessage: `PONYTAIL:${mode.toUpperCase()}` };
if (context) {
output.hookSpecificOutput = {
@@ -29,11 +37,15 @@ function writeHookOutput(event, mode, context = '') {
};
}
process.stdout.write(JSON.stringify(output));
return;
}
process.stdout.write(context);
}
module.exports = {
clearMode,
isCodex,
isCopilot,
setMode,
writeHookOutput,
};
+3
View File
@@ -4,6 +4,9 @@
"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",
"scripts": {
"test": "node --test tests/*.test.js && npm test --prefix pi-extension"
},
"pi": {
"extensions": ["./pi-extension/index.js"],
"skills": ["./skills"]
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
// Generate the OpenClaw / ClawHub skill package (.openclaw/skills/) from the
// canonical skills/. OpenClaw skills are SKILL.md (frontmatter + body), the same
// format ponytail already uses, with one difference: `description` must be a
// single line under 160 chars. The canonical descriptions are long (tuned for
// Claude's skill picker), so each ships a short one here. The body is copied
// verbatim from skills/<name>/SKILL.md so the ruleset never drifts; only the
// frontmatter is rewritten.
//
// Run: node scripts/build-openclaw-skills.js
// tests/openclaw-skills.test.js fails if the committed copies are stale.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const HOMEPAGE = 'https://github.com/DietrichGebert/ponytail';
const DESCRIPTIONS = {
'ponytail': 'Lazy senior dev mode. Forces the simplest, shortest solution that works: YAGNI, stdlib first, no unrequested abstractions.',
'ponytail-review': 'Review a diff for over-engineering. Finds what to delete: reinvented stdlib, needless deps, speculative abstractions. One line per finding.',
'ponytail-audit': 'Audit the whole repo for over-engineering. A ranked list of what to delete, simplify, or replace with stdlib or native features.',
'ponytail-debt': 'Harvest every ponytail: shortcut comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report.',
'ponytail-help': "Quick reference for ponytail's modes, skills, and commands. One-shot display.",
};
const NAMES = Object.keys(DESCRIPTIONS);
function sourceBody(name) {
const src = fs.readFileSync(path.join(ROOT, 'skills', name, 'SKILL.md'), 'utf8').replace(/\r\n/g, '\n');
const fm = src.match(/^---\n[\s\S]*?\n---\n?/);
if (!fm) throw new Error(`skills/${name}/SKILL.md has no frontmatter`);
return src.slice(fm[0].length);
}
function render(name) {
const desc = DESCRIPTIONS[name];
if (desc.length > 160 || desc.includes('\n') || desc.includes('"')) {
throw new Error(`description for ${name} must be one line, no quotes, under 160 chars`);
}
const frontmatter =
`---\nname: ${name}\ndescription: "${desc}"\nhomepage: ${HOMEPAGE}\nlicense: MIT\n---\n`;
return frontmatter + sourceBody(name);
}
function outPath(name) {
return path.join(ROOT, '.openclaw', 'skills', name, 'SKILL.md');
}
module.exports = { DESCRIPTIONS, NAMES, render, outPath, sourceBody };
if (require.main === module) {
for (const name of NAMES) {
const p = outPath(name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, render(name));
console.log('wrote', path.relative(ROOT, p).replace(/\\/g, '/'));
}
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
// Every ponytail command the pi extension registers must also ship as a
// file-based command for the hosts that need one: Claude Code (commands/*.toml,
// which Gemini CLI reuses) and OpenCode (.opencode/command/*.md). /ponytail-help
// was advertised in the README and the help card but missing both files; this
// guards that drift -- a registered command with no adapter file fails here.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
// pi-extension registers the canonical command set.
const piSource = fs.readFileSync(path.join(root, 'pi-extension', 'index.js'), 'utf8');
const commands = [...piSource.matchAll(/registerCommand\(["']([\w-]+)["']/g)].map((m) => m[1]);
test('pi registers at least the base command', () => {
assert.ok(commands.includes('ponytail'), 'expected pi to register a ponytail command');
});
test('every registered command ships a Claude commands/*.toml', () => {
for (const name of commands) {
assert.ok(
fs.existsSync(path.join(root, 'commands', `${name}.toml`)),
`missing commands/${name}.toml`,
);
}
});
test('every registered command ships an OpenCode .opencode/command/*.md', () => {
for (const name of commands) {
assert.ok(
fs.existsSync(path.join(root, '.opencode', 'command', `${name}.md`)),
`missing .opencode/command/${name}.md`,
);
}
});
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
// Smoke test for the Copilot plugin adapter: keep command wiring minimal and
// ensure the debt command is part of the shared command surface.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const REQUIRED_COMMAND_FILES = [
'ponytail.toml',
'ponytail-review.toml',
'ponytail-audit.toml',
'ponytail-debt.toml',
];
function readJSON(relPath) {
return JSON.parse(fs.readFileSync(path.join(root, relPath), 'utf8'));
}
test('copilot plugin command directory includes ponytail-debt', () => {
const manifest = readJSON('.github/plugin/plugin.json');
assert.equal(manifest.name, 'ponytail');
assert.equal(manifest.commands, 'commands/');
for (const file of REQUIRED_COMMAND_FILES) {
assert.ok(
fs.existsSync(path.join(root, manifest.commands, file)),
`missing command file: ${manifest.commands}${file}`,
);
}
});
+15 -3
View File
@@ -16,6 +16,12 @@ const MANIFEST = 'gemini-extension.json';
const EXTENSION_NAME = 'ponytail';
// Floating refs are a supply-chain footgun; the manifest version must be pinned.
const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;
const VERSIONED_MANIFESTS = [
'gemini-extension.json',
'.claude-plugin/plugin.json',
'.codex-plugin/plugin.json',
'.github/plugin/plugin.json',
];
// Gemini auto-discovers these by directory; the manifest is only useful if they exist.
const REUSED_COMMANDS = ['commands/ponytail.toml', 'commands/ponytail-review.toml'];
const REUSED_SKILLS = ['skills/ponytail/SKILL.md'];
@@ -46,9 +52,15 @@ test('manifest names the ponytail extension with a pinned version', () => {
});
test('version stays aligned with the other plugin manifests', () => {
const manifest = loadManifest();
const claude = JSON.parse(read('.claude-plugin/plugin.json'));
assert.equal(manifest.version, claude.version);
const versions = VERSIONED_MANIFESTS.map((rel) => {
const manifest = JSON.parse(read(rel));
assert.match(manifest.version, PINNED_SEMVER, `${rel} version must be pinned semver`);
return manifest.version;
});
const [sharedVersion, ...rest] = versions;
for (const version of rest) {
assert.equal(version, sharedVersion);
}
});
test('contextFileName resolves to a file carrying the ponytail rules', () => {
+39
View File
@@ -99,5 +99,44 @@ assert.equal(
'flag must not land in ~/.claude when CLAUDE_CONFIG_DIR is set',
);
const copilotData = path.join(temp, 'copilot-data');
const codexData = path.join(temp, 'codex-data-shadow');
result = run('ponytail-activate.js', {
HOME: home,
USERPROFILE: home,
COPILOT_PLUGIN_DATA: copilotData,
PLUGIN_DATA: codexData,
PONYTAIL_DEFAULT_MODE: 'full',
});
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(path.join(copilotData, '.ponytail-active'), 'utf8'), 'full');
assert.equal(
fs.existsSync(path.join(codexData, '.ponytail-active')),
false,
'copilot hooks must not write mode state to codex PLUGIN_DATA',
);
output = JSON.parse(result.stdout);
assert.match(output.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
result = run(
'ponytail-mode-tracker.js',
{
HOME: home,
USERPROFILE: home,
COPILOT_PLUGIN_DATA: copilotData,
PLUGIN_DATA: codexData,
},
JSON.stringify({ prompt: '/ponytail ultra' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(path.join(copilotData, '.ponytail-active'), 'utf8'), 'ultra');
assert.equal(
fs.existsSync(path.join(codexData, '.ponytail-active')),
false,
'copilot mode tracker must keep codex PLUGIN_DATA untouched',
);
output = JSON.parse(result.stdout);
assert.deepEqual(output, {});
fs.rmSync(temp, { recursive: true, force: true });
console.log('hook compatibility checks passed');
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// The OpenClaw skill package (.openclaw/skills/) is generated from skills/ by
// scripts/build-openclaw-skills.js. These tests fail if the committed copies are
// stale (ruleset drift) or if a description breaks OpenClaw's one-line <160 rule.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const { NAMES, render, outPath, sourceBody, DESCRIPTIONS } = require('../scripts/build-openclaw-skills');
for (const name of NAMES) {
test(`${name}: committed OpenClaw skill matches the generator`, () => {
const onDisk = fs.readFileSync(outPath(name), 'utf8').replace(/\r\n/g, '\n');
assert.equal(onDisk, render(name), 'stale — run: node scripts/build-openclaw-skills.js');
});
test(`${name}: body is the canonical skills/${name} body, verbatim`, () => {
const onDisk = fs.readFileSync(outPath(name), 'utf8').replace(/\r\n/g, '\n');
assert.ok(onDisk.endsWith(sourceBody(name)), 'body drifted from skills/' + name);
});
test(`${name}: description is one line under 160 chars`, () => {
const d = DESCRIPTIONS[name];
assert.ok(d.length <= 160 && !d.includes('\n'), 'description too long or multiline');
});
}