Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e181b89ac0 | ||
|
|
48cdf05a25 | ||
|
|
a4e5e479d6 | ||
|
|
fe963cae99 | ||
|
|
b0c5820bb1 | ||
|
|
83f67a261a | ||
|
|
a28e5ec123 | ||
|
|
4dad14fac5 | ||
|
|
0ac987f995 | ||
|
|
15749f7ffc | ||
|
|
37f46b8f02 | ||
|
|
e782790b15 | ||
|
|
b345e49385 | ||
|
|
7f4dc907fc | ||
|
|
e7e09f8fd4 | ||
|
|
766c5ca5b1 | ||
|
|
25be875fab | ||
|
|
70df716a02 | ||
|
|
6d35c10920 | ||
|
|
795ec0ee36 | ||
|
|
c30854118e | ||
|
|
a3bc7db722 | ||
|
|
55b7cb1925 | ||
|
|
53fd1e850e | ||
|
|
955fff537c | ||
|
|
44babb22ef | ||
|
|
91aef5dfef |
@@ -0,0 +1,24 @@
|
|||||||
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
|
Before writing any code, stop at the first rung that holds:
|
||||||
|
|
||||||
|
1. Does this need to be built at all? (YAGNI)
|
||||||
|
2. Does the standard library already do this? Use it.
|
||||||
|
3. Does a native platform feature cover it? Use it.
|
||||||
|
4. Does an already-installed dependency solve it? Use it.
|
||||||
|
5. Can this be one line? Make it one line.
|
||||||
|
6. Only then: write the minimum code that works.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- No abstractions that weren't explicitly requested.
|
||||||
|
- No new dependency if it can be avoided.
|
||||||
|
- No boilerplate nobody asked for.
|
||||||
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
|
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
|
||||||
|
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
|
||||||
|
|
||||||
|
Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
|
||||||
@@ -5,5 +5,6 @@
|
|||||||
"author": {
|
"author": {
|
||||||
"name": "Dietrich Gebert",
|
"name": "Dietrich Gebert",
|
||||||
"url": "https://github.com/DietrichGebert"
|
"url": "https://github.com/DietrichGebert"
|
||||||
}
|
},
|
||||||
|
"hooks": "./hooks/claude-codex-hooks.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["yagni", "minimalism", "code-review", "productivity"],
|
"keywords": ["yagni", "minimalism", "code-review", "productivity"],
|
||||||
"skills": "./skills/",
|
"skills": "./skills/",
|
||||||
|
"hooks": "./hooks/claude-codex-hooks.json",
|
||||||
"interface": {
|
"interface": {
|
||||||
"displayName": "Ponytail",
|
"displayName": "Ponytail",
|
||||||
"shortDescription": "Lazy senior developer mode",
|
"shortDescription": "Lazy senior developer mode",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. S
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Complexity only, correctness bugs, security holes, and performance go to a
|
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
||||||
normal review pass. Lists findings, applies nothing. One-shot.
|
and performance are explicitly out of scope — route them to a normal review
|
||||||
|
pass. Lists findings, applies nothing. One-shot.
|
||||||
"stop ponytail-audit" or "normal mode" to revert.
|
"stop ponytail-audit" or "normal mode" to revert.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: ponytail-gain
|
||||||
|
description: "Show ponytail measured impact as a scoreboard: less code, less cost, more speed, from the benchmark medians. One-shot display."
|
||||||
|
homepage: https://github.com/DietrichGebert/ponytail
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ponytail Gain
|
||||||
|
|
||||||
|
Display this scoreboard when invoked. One-shot: do NOT change mode, write flag
|
||||||
|
files, or persist anything.
|
||||||
|
|
||||||
|
The figures are the published benchmark medians (5 everyday tasks: email
|
||||||
|
validator, debounce, CSV sum, countdown timer, rate limiter; three models:
|
||||||
|
Haiku, Sonnet, Opus). They are measured, not computed from the current repo.
|
||||||
|
Source: `benchmarks/` and the README.
|
||||||
|
|
||||||
|
## Scoreboard
|
||||||
|
|
||||||
|
Render plain ASCII bars. The bar length shows the measured range; the label
|
||||||
|
carries the exact figure:
|
||||||
|
|
||||||
|
```
|
||||||
|
ponytail gain benchmark median · 5 tasks · 3 models
|
||||||
|
|
||||||
|
Lines of code no-skill ████████████████████ 100%
|
||||||
|
ponytail ██▌················· 6–20% ▼ 80–94%
|
||||||
|
Cost no-skill ████████████████████ 100%
|
||||||
|
ponytail █████▌·············· 23–53% ▼ 47–77%
|
||||||
|
Speed ponytail ▸ 3–6× faster
|
||||||
|
|
||||||
|
This repo: /ponytail-debt (shortcuts you deferred)
|
||||||
|
/ponytail-audit (what's still cuttable)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Honesty boundary
|
||||||
|
|
||||||
|
These are benchmark medians, not this repo. NEVER print a per-repo savings
|
||||||
|
number ("you saved X lines/tokens here"): the unbuilt version was never
|
||||||
|
written, so there is no real baseline to subtract from in a live repo. The
|
||||||
|
only real per-repo figures come from `/ponytail-debt` (a counted ledger), and
|
||||||
|
this card points there instead of inventing one.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
One-shot display. Edits nothing, changes no mode.
|
||||||
|
"stop ponytail" or "normal mode": revert.
|
||||||
@@ -26,6 +26,7 @@ Level sticks until changed or session end.
|
|||||||
|-------|---------|--------------|
|
|-------|---------|--------------|
|
||||||
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
|
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
|
||||||
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
|
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
|
||||||
|
| **ponytail-gain** | `/ponytail-gain` | Measured-impact scoreboard: less code, less cost, more speed. |
|
||||||
| **ponytail-help** | `/ponytail-help` | This card. |
|
| **ponytail-help** | `/ponytail-help` | This card. |
|
||||||
|
|
||||||
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ If there is nothing to cut, say `Lean already. Ship.` and stop.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Complexity only, correctness bugs, security holes, and performance go to a
|
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
||||||
normal review pass, not this one. A single smoke test or `assert`-based
|
and performance are explicitly out of scope — route them 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.
|
self-check is the ponytail minimum, not bloat, never flag it for deletion.
|
||||||
Does not apply the fixes, only lists them.
|
Does not apply the fixes, only lists them.
|
||||||
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Harvest ponytail: comments into a tracked debt ledger
|
description: "Harvest ponytail: comments into a tracked debt ledger"
|
||||||
---
|
---
|
||||||
|
|
||||||
Harvest every `ponytail:` comment in this repository into a debt ledger so deferrals do not rot into 'later means never'. Grep the whole tree for comment markers (grep -rnE '(#|//) ?ponytail:' ., skipping node_modules/.git/build output). One row per marker, grouped by file: <file>:<line> — <what was simplified>. ceiling: <the limit named in the comment>. upgrade: <the trigger to revisit>. Tag any marker that names no upgrade path or trigger as no-trigger, those rot silently. End with the count of markers and how many lack a trigger. If none: 'No ponytail: debt. Clean ledger.' Report only, change nothing.
|
Harvest every `ponytail:` comment in this repository into a debt ledger so deferrals do not rot into 'later means never'. Grep the whole tree for comment markers (grep -rnE '(#|//) ?ponytail:' ., skipping node_modules/.git/build output). One row per marker, grouped by file: <file>:<line> — <what was simplified>. ceiling: <the limit named in the comment>. upgrade: <the trigger to revisit>. Tag any marker that names no upgrade path or trigger as no-trigger, those rot silently. End with the count of markers and how many lack a trigger. If none: 'No ponytail: debt. Clean ledger.' Report only, change nothing.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: Show ponytail's measured impact scoreboard (less code, cost, time)
|
||||||
|
---
|
||||||
|
|
||||||
|
Show the ponytail gain scoreboard. One shot, change nothing: do not switch mode, write flag files, or persist anything. Render the published benchmark medians (5 everyday tasks; models Haiku, Sonnet, Opus; source benchmarks/ and the README) as plain ASCII bars: Lines of code, no-skill 100% vs ponytail 6-20% (down 80-94%); Cost, no-skill 100% vs ponytail 23-53% (down 47-77%); Speed, ponytail 3-6x faster. The bar length shows the measured range, the label carries the exact figure. These are benchmark medians, not this repo. NEVER print a per-repo savings number: the unbuilt version was never written, so there is no real baseline to subtract from in a live repo. For real per-repo figures, point to /ponytail-debt (the counted shortcut ledger) and /ponytail-audit (what is still cuttable). Report only.
|
||||||
@@ -11,6 +11,9 @@ import { createRequire } from 'module';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// The shared instruction builder is CommonJS; bridge to it from this ES module.
|
// The shared instruction builder is CommonJS; bridge to it from this ES module.
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
@@ -42,7 +45,18 @@ export default async ({ client } = {}) => {
|
|||||||
try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {}
|
try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ponytailSkillsDir = path.resolve(__dirname, '../../skills');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// Register skills directory so opencode discovers ponytail skills.
|
||||||
|
config: async (config) => {
|
||||||
|
config.skills = config.skills || {};
|
||||||
|
config.skills.paths = config.skills.paths || [];
|
||||||
|
if (!config.skills.paths.includes(ponytailSkillsDir)) {
|
||||||
|
config.skills.paths.push(ponytailSkillsDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Append the ruleset to the system prompt every turn.
|
// Append the ruleset to the system prompt every turn.
|
||||||
'experimental.chat.system.transform': async (_input, output) => {
|
'experimental.chat.system.transform': async (_input, output) => {
|
||||||
const mode = readMode();
|
const mode = readMode();
|
||||||
|
|||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.png">
|
||||||
|
<img src="assets/logo.png" width="220" alt="Ponytail, el senior dev flojo">
|
||||||
|
</picture>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">Ponytail</h1>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<em>No dice nada. Escribe una línea. Funciona.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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/funciona%20con-14%20agentes-111111?style=flat-square" alt="Works with 14 agents">
|
||||||
|
<img src="https://img.shields.io/badge/licencia-MIT-111111?style=flat-square" alt="MIT license">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>~54% menos código (hasta 94%) · ~20% más barato · ~27% más rápido · 100% seguro</strong><br>
|
||||||
|
<sub>Medido en sesiones reales de Claude Code editando un repo open-source real (FastAPI + React), contra el mismo agente sin skill. ~54% es el promedio de 12 tareas de feature (Haiku 4.5, n=4); llega al 94% cuando un agente sobre-construye (un selector de fechas) y es casi cero cuando el código ya es mínimo. ponytail mantiene cada guarda de seguridad, mientras que un prompt pelado de "escribe one-liners" se salta una. (El benchmark anterior de un solo disparo reportaba 80-94% como cifra plana; contra un baseline agéntico justo, ese es el techo por tarea, no el promedio.) <a href="benchmarks/results/2026-06-18-agentic.md">Reporte completo</a> · <a href="benchmarks/">reprodúcelo</a>.</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Traducción de la comunidad. La versión de referencia y más reciente es el <a href="README.md">README en inglés</a>.</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Lo conoces. Cola de caballo larga. Lentes ovalados. Lleva más tiempo en la empresa que el control de versiones. Le muestras cincuenta líneas; las mira, no dice nada, y las reemplaza por una.
|
||||||
|
|
||||||
|
Ponytail lo pone dentro de tu agente de IA.
|
||||||
|
|
||||||
|
## Antes / después
|
||||||
|
|
||||||
|
Le pides un selector de fechas. Tu agente instala flatpickr, escribe un componente wrapper, agrega un stylesheet, y empieza una discusión sobre zonas horarias.
|
||||||
|
|
||||||
|
Con ponytail:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ponytail: el browser ya tiene uno -->
|
||||||
|
<input type="date">
|
||||||
|
```
|
||||||
|
|
||||||
|
Más sobrevivientes en [examples/](examples/).
|
||||||
|
|
||||||
|
> **Combina bien con** [Modern Web Guidance](https://github.com/GoogleChrome/modern-web-guidance) para trabajo web: ponytail decide *si* apoyarse en la plataforma, MWG es cómo el agente busca *qué* feature nativa hace el trabajo. Ver [examples/web-platform-lookup.md](examples/web-platform-lookup.md).
|
||||||
|
|
||||||
|
## Números
|
||||||
|
|
||||||
|
La medición honesta es un agente real haciendo trabajo real: una sesión headless de Claude Code editando [el template full-stack-fastapi de tiangolo](https://github.com/fastapi/full-stack-fastapi-template) (un repo real de FastAPI + React), evaluada sobre el `git diff` que deja. Doce tickets de feature, el mismo agente con y sin el skill, n=4, Haiku 4.5.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/benchmark-agentic.svg" width="860" alt="Cada variante como porcentaje del baseline sin skill en LOC, tokens, costo y tiempo (Haiku 4.5). ponytail es el más bajo en cada métrica (LOC 46%, tokens 78%, costo 80%, tiempo 73%); caveman sube por encima del 100% en tokens, costo y tiempo; yagni-oneliner LOC 67%. Seguridad, tier adversarial aparte: baseline, caveman y ponytail 100%, yagni-oneliner 95%.">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
| vs baseline sin skill | LOC | tokens | costo | tiempo | seguro |
|
||||||
|
|---|--:|--:|--:|--:|--:|
|
||||||
|
| **ponytail** | **-54%** | **-22%** | **-20%** | **-27%** | **100%** |
|
||||||
|
| caveman (control de prosa concisa) | -20% | +7% | +3% | +2% | 100% |
|
||||||
|
| prompt "YAGNI + one-liners" | -33% | -14% | -21% | -30% | 95% |
|
||||||
|
|
||||||
|
ponytail es la única variante que recorta cada métrica, y la única que se mantiene totalmente segura al hacerlo. El recorte es mayor donde hay una trampa real de sobre-construcción (selector de fechas de 404 a 23 líneas, selector de color de 287 a 23, porque usa un `<input>` nativo en vez de un componente) y casi cero en código que ya es mínimo. Método completo, tablas por tarea y limitaciones: [benchmarks/results/2026-06-18-agentic.md](benchmarks/results/2026-06-18-agentic.md).
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Números anteriores de un solo disparo (generación aislada)</strong></summary>
|
||||||
|
|
||||||
|
Cinco tareas del día a día, tres modelos, tres variantes (sin skill, [caveman](https://github.com/JuliusBrussee/caveman), ponytail), diez ejecuciones, mediana reportada. Un prompt, una completación, contando las líneas de la respuesta:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/benchmark-3model.svg" width="860" alt="Mediana de líneas de código por variante en Haiku, Sonnet y Opus">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
Esto mostraba **80-94% menos código**. [#126](https://github.com/DietrichGebert/ponytail/issues/126) señaló con razón que el baseline del modelo pelado infla su respuesta con prosa y opciones, así que esa diferencia es en parte un artefacto del baseline conversacional. Los números agénticos de arriba son la versión corregida y defendible. Reproduce la corrida de un solo disparo con `npx promptfoo eval -c benchmarks/promptfooconfig.yaml`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**La regla nunca fue "menos tokens."** Es: escribe solo lo que la tarea necesita, y nunca recortes validación, manejo de errores, seguridad ni accesibilidad. El código termina pequeño porque es necesario, no por golf. El menor costo y latencia son un efecto secundario en los modelos que siguen la escalera; un modelo de razonamiento conciso que gasta tokens de pensamiento deliberando los peldaños puede ir al revés (en GPT-5.5 lo hace).
|
||||||
|
|
||||||
|
## Cómo funciona
|
||||||
|
|
||||||
|
Antes de escribir código, el agente se detiene en el primer peldaño que aguanta:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ¿Necesita existir esto? → no: omitirlo (YAGNI)
|
||||||
|
2. ¿Lo hace la stdlib? → úsala
|
||||||
|
3. ¿Es una feature nativa? → úsala
|
||||||
|
4. ¿Una dependencia ya instalada? → úsala
|
||||||
|
5. ¿Cabe en una línea? → una línea
|
||||||
|
6. Solo entonces: el mínimo que funciona
|
||||||
|
```
|
||||||
|
|
||||||
|
Flojo, no negligente: la validación en límites de confianza, el manejo de pérdida de datos, la seguridad y la accesibilidad nunca están en riesgo.
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
El mayor esfuerzo que ponytail te va a pedir:
|
||||||
|
|
||||||
|
Los plugins de Claude Code y Codex ejecutan dos pequeños lifecycle hooks de Node.js, así que `node` debe estar en tu PATH (nota para usuarios de Nix/nvm: debe estar en el PATH del shell no-interactivo). Si no lo está, los skills igualmente funcionan — la activación automática simplemente queda en silencio en vez de lanzar un error en cada prompt.
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add DietrichGebert/ponytail
|
||||||
|
/plugin install ponytail@ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
La app de escritorio no tiene el comando `/plugin`. Instálala desde la interfaz: Customize, el + junto a los plugins personales, Create plugin and add marketplace, Add from repository, y luego ingresa la URL del repo (gracias @NiklasDHahn, #98).
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex plugin marketplace add DietrichGebert/ponytail
|
||||||
|
codex
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre `/plugins`, selecciona el marketplace de Ponytail e instala Ponytail. Luego abre `/hooks`, revisa y autoriza sus dos lifecycle hooks, y empieza un nuevo hilo.
|
||||||
|
|
||||||
|
Esta misma instalación cubre también la app de escritorio de Codex: reinicia la app después de instalar y detecta el plugin automáticamente.
|
||||||
|
|
||||||
|
### GitHub Copilot CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copilot plugin marketplace add DietrichGebert/ponytail
|
||||||
|
copilot plugin install ponytail@ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
En una sesión interactiva de Copilot CLI, usa los equivalentes con slash:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add DietrichGebert/ponytail
|
||||||
|
/plugin install ponytail@ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
Copilot CLI agrupa los comandos del plugin bajo el nombre del plugin. Por ejemplo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ponytail:ponytail ultra
|
||||||
|
/ponytail:ponytail-review
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pi agent harness
|
||||||
|
|
||||||
|
```
|
||||||
|
pi install git:github.com/DietrichGebert/ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenCode
|
||||||
|
|
||||||
|
Ejecuta OpenCode desde un checkout de este repo (el plugin reutiliza sus `hooks/` y `skills/`), y agrega esto a `opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Inyecta el ruleset en cada turno con el nivel activo; agrega los comandos `/ponytail` (ver [Comandos](#comandos)). OpenCode también carga automáticamente el `AGENTS.md` de este repo, así que las reglas aplican incluso sin el plugin. El plugin agrega los niveles `lite/full/ultra/off`.
|
||||||
|
|
||||||
|
El path `./` se resuelve contra el `opencode.json` de tu proyecto; para compartir un único checkout entre proyectos, apunta al path absoluto del `.mjs` (encuentra sus `hooks/` y `skills/` relativo a su propio archivo).
|
||||||
|
|
||||||
|
### Gemini CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini extensions install https://github.com/DietrichGebert/ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
Carga el ruleset como contexto permanente en cada sesión y registra los comandos `/ponytail`; los `skills/` también se incluyen, activados cuando una tarea los necesita.
|
||||||
|
|
||||||
|
### Antigravity CLI
|
||||||
|
|
||||||
|
Google está renombrando Gemini CLI a Antigravity CLI (el binario `agy`); la misma extensión se instala ahí:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agy plugin install https://github.com/DietrichGebert/ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
Reutiliza el `gemini-extension.json` de este repo. Una diferencia: Antigravity convierte los comandos `/ponytail` en skills, así que los escribes en el chat (por ejemplo `/ponytail-review` como mensaje) en vez de seleccionarlos de un menú slash. Hasta que la migración se complete (alrededor del 18 de junio de 2026), `gemini extensions install` también funciona. Para usarlo como regla permanente, coloca el ruleset en `.agents/rules/`.
|
||||||
|
|
||||||
|
### CodeWhale
|
||||||
|
|
||||||
|
Lee `AGENTS.md` desde la raíz del proyecto, sin configuración. Copia [`AGENTS.md`](AGENTS.md) a tu proyecto, o ejecuta `codewhale` desde un checkout de este repo. Eso es todo.
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawhub install ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
Instala ponytail como skill de OpenClaw desde ClawHub; los skills de review, audit, debt y help se instalan igual (`clawhub install ponytail-review`, etc.). OpenClaw lo aplica en tareas de código y también lo expone como comando `/ponytail`. Sin ClawHub, copia [`.openclaw/skills/ponytail`](.openclaw/skills/) a `~/.openclaw/skills/`.
|
||||||
|
|
||||||
|
Eso fue todo. Él estaría orgulloso. No lo va a decir.
|
||||||
|
|
||||||
|
Activo en cada sesión, con un puñado de comandos (ver [Comandos](#comandos)). `/ponytail ultra` existe para cuando el codebase te hizo algo personal. El texto de inicio y de cambio de modo muestra el nivel activo.
|
||||||
|
|
||||||
|
Configura el nivel para cada nueva sesión con la variable de entorno `PONYTAIL_DEFAULT_MODE` (`lite`/`full`/`ultra`/`off`), o con un campo `defaultMode` en `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` en Windows). El default es `full`.
|
||||||
|
|
||||||
|
Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro: copia el archivo de reglas correspondiente de este 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: copia `.kiro/steering/ponytail.md` a `~/.kiro/steering/` (global) o `.kiro/steering/` en tu proyecto.
|
||||||
|
|
||||||
|
Fallback de GitHub Copilot CLI (modo solo instrucciones): lee `AGENTS.md` y `.github/copilot-instructions.md` en un proyecto, o copia las reglas a `~/.copilot/copilot-instructions.md` para ejecutar ponytail en todos tus proyectos. Esta vía mantiene la guía permanente, pero no agrega switches de modo ni hooks.
|
||||||
|
|
||||||
|
VS Code con la extensión Codex lee `AGENTS.md`, que este repo incluye, así que funciona desde la raíz del repo sin configuración adicional (`~/.codex/AGENTS.md` hace a Codex global).
|
||||||
|
|
||||||
|
Qué archivos corresponden a qué agente: [Portabilidad de agentes](docs/agent-portability.md).
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
| Comando | Qué hace |
|
||||||
|
|---------|----------|
|
||||||
|
| `/ponytail [lite \| full \| ultra \| off]` | Cambia la intensidad, o apágalo. Sin argumento, reporta el nivel actual. |
|
||||||
|
| `/ponytail-review` | Revisa el diff actual en busca de sobre-ingeniería y devuelve una lista de qué eliminar. |
|
||||||
|
| `/ponytail-audit` | Audita el repo completo en busca de sobre-ingeniería, no solo el diff. |
|
||||||
|
| `/ponytail-debt` | Recolecta los atajos marcados con `ponytail:` que dejaste pendientes en un registro, para que "después" no se convierta en "nunca". |
|
||||||
|
| `/ponytail-help` | Referencia rápida de los comandos anteriores. |
|
||||||
|
|
||||||
|
Los comandos requieren un host compatible con skills (Claude Code, Codex, OpenCode, Gemini, pi). En Codex son skills; se invocan con `@` (`@ponytail-review`). Los adaptadores de solo instrucciones (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) cargan el ruleset permanente sin los comandos.
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
Al cambiar el texto compacto de las reglas, mantén alineadas las copias en los adaptadores:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/check-rule-copies.js
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
El paquete de skills de OpenClaw (`.openclaw/skills/`) se genera desde `skills/`; ejecuta `node scripts/build-openclaw-skills.js` después de cambiar un skill — la suite de tests falla si está desactualizado.
|
||||||
|
|
||||||
|
El benchmark de correctness lanza Python para las verificaciones de email y CSV; se prueba `python3` antes que `python`. Las verificaciones de CSV requieren `pandas` instalado localmente.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**¿Necesita un archivo de configuración?**
|
||||||
|
No. Un opcional `~/.config/ponytail/config.json` o la variable `PONYTAIL_DEFAULT_MODE` pueden fijar el nivel default, pero nada es obligatorio.
|
||||||
|
|
||||||
|
**¿Y si realmente necesito la clase de caché de 120 líneas?**
|
||||||
|
No la necesitas. Insiste de todas formas y él la va a construir. Despacio. Correctamente. Mirándote.
|
||||||
|
|
||||||
|
**¿Escala?**
|
||||||
|
El código que nunca escribiste escala infinitamente. Cero bugs, cero CVEs, 100% uptime desde siempre.
|
||||||
|
|
||||||
|
**¿Por qué "ponytail"?**
|
||||||
|
Ya sabes exactamente por qué.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
[MIT](LICENSE). La licencia más corta que funciona.
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<p align="center">
|
<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/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/github/v/release/DietrichGebert/ponytail?style=flat-square&color=111111&label=release" alt="Release">
|
||||||
<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/works%20with-14%20agents-111111?style=flat-square" alt="Works with 14 agents">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
|
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -23,6 +23,10 @@
|
|||||||
<sub>Measured on real Claude Code sessions editing a real open-source repo (FastAPI + React), against the same agent with no skill. ~54% is the mean across 12 feature tasks (Haiku 4.5, n=4); it reaches 94% where an agent over-builds (a date picker) and is near zero where the code is already minimal. ponytail keeps every safety guard while a bare "write one-liners" prompt drops one. (The earlier single-shot benchmark reported 80-94% as a flat figure; against a fair agentic baseline that is the per-task ceiling, not the average.) <a href="benchmarks/results/2026-06-18-agentic.md">Full writeup</a> · <a href="benchmarks/">reproduce it</a>.</sub>
|
<sub>Measured on real Claude Code sessions editing a real open-source repo (FastAPI + React), against the same agent with no skill. ~54% is the mean across 12 feature tasks (Haiku 4.5, n=4); it reaches 94% where an agent over-builds (a date picker) and is near zero where the code is already minimal. ponytail keeps every safety guard while a bare "write one-liners" prompt drops one. (The earlier single-shot benchmark reported 80-94% as a flat figure; against a fair agentic baseline that is the per-task ceiling, not the average.) <a href="benchmarks/results/2026-06-18-agentic.md">Full writeup</a> · <a href="benchmarks/">reproduce it</a>.</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub><a href="README.es.md">Español</a></sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You know him. Long ponytail. Oval glasses. Has been at the company longer than the version control. You show him fifty lines; he looks at them, says nothing, and replaces them with one.
|
You know him. Long ponytail. Oval glasses. Has been at the company longer than the version control. You show him fifty lines; he looks at them, says nothing, and replaces them with one.
|
||||||
@@ -42,6 +46,8 @@ With ponytail:
|
|||||||
|
|
||||||
More survivors in [examples/](examples/).
|
More survivors in [examples/](examples/).
|
||||||
|
|
||||||
|
> **Pairs well with** [Modern Web Guidance](https://github.com/GoogleChrome/modern-web-guidance) for web work: ponytail decides *whether* to lean on the platform, MWG is how the agent looks up *which* native feature does the job. See [examples/web-platform-lookup.md](examples/web-platform-lookup.md).
|
||||||
|
|
||||||
## Numbers
|
## Numbers
|
||||||
|
|
||||||
The honest measurement is a real agent doing real work: a headless Claude Code session editing [tiangolo's full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template) (a real FastAPI + React repo), scored on the `git diff` it leaves behind. Twelve feature tickets, the same agent with and without the skill, n=4, Haiku 4.5.
|
The honest measurement is a real agent doing real work: a headless Claude Code session editing [tiangolo's full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template) (a real FastAPI + React repo), scored on the `git diff` it leaves behind. Twelve feature tickets, the same agent with and without the skill, n=4, Haiku 4.5.
|
||||||
@@ -101,6 +107,8 @@ The Claude Code and Codex plugins run two tiny Node.js lifecycle hooks, so `node
|
|||||||
/plugin install ponytail@ponytail
|
/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
|
### Codex
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -152,6 +160,8 @@ Injects the ruleset every turn at the active level; adds the `/ponytail` command
|
|||||||
|
|
||||||
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 `./` 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
|
### Gemini CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -159,6 +169,7 @@ 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.
|
Loads the ruleset as always-on context every session and registers the `/ponytail` commands; the `skills/` ship too, activated when a task needs them.
|
||||||
|
The Gemini adapter intentionally does not ship a root `hooks/hooks.json`: Gemini auto-loads that path, while Ponytail's lifecycle hooks use Claude/Codex event names.
|
||||||
|
|
||||||
### Antigravity CLI
|
### Antigravity CLI
|
||||||
|
|
||||||
@@ -170,13 +181,17 @@ 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/`.
|
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/`.
|
||||||
|
|
||||||
|
### CodeWhale
|
||||||
|
|
||||||
|
Reads `AGENTS.md` from the project root — zero setup. Copy [`AGENTS.md`](AGENTS.md) to your project, or run `codewhale` from a checkout of this repo. That's it.
|
||||||
|
|
||||||
### OpenClaw
|
### OpenClaw
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawhub install ponytail
|
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/`.
|
Installs ponytail as an OpenClaw skill from ClawHub; the review, audit, debt, gain, 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.
|
That was it. He'd be proud. He won't say it.
|
||||||
|
|
||||||
@@ -184,7 +199,7 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/
|
|||||||
|
|
||||||
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`.
|
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/)).
|
Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro, Zed, CodeWhale: 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.
|
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
|
||||||
|
|
||||||
@@ -202,6 +217,7 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md).
|
|||||||
| `/ponytail-review` | Review the current diff for over-engineering, hands back a delete-list. |
|
| `/ponytail-review` | Review the current diff for over-engineering, hands back a delete-list. |
|
||||||
| `/ponytail-audit` | Audit the whole repo for over-engineering, not just the diff. |
|
| `/ponytail-audit` | Audit the whole repo for over-engineering, not just the diff. |
|
||||||
| `/ponytail-debt` | Harvest the `ponytail:` shortcuts you've deferred into a ledger, so "later" doesn't become "never". |
|
| `/ponytail-debt` | Harvest the `ponytail:` shortcuts you've deferred into a ledger, so "later" doesn't become "never". |
|
||||||
|
| `/ponytail-gain` | Show the measured impact scoreboard (less code, less cost, more speed) from the benchmark. |
|
||||||
| `/ponytail-help` | Quick reference for the commands above. |
|
| `/ponytail-help` | Quick reference for the commands above. |
|
||||||
|
|
||||||
Commands need a skill-capable host (Claude Code, Codex, OpenCode, Gemini, pi). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
|
Commands need a skill-capable host (Claude Code, Codex, OpenCode, Gemini, pi). In Codex they're skills, invoke with `@` (`@ponytail-review`). The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Requires an Anthropic API key and **Node.js ≥ 22.22.0** (promptfoo's engine co
|
|||||||
check with `node --version` and upgrade if needed):
|
check with `node --version` and upgrade if needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp ../.env.example ../.env # add your ANTHROPIC_API_KEY
|
cp ../.env.example .env # add your ANTHROPIC_API_KEY
|
||||||
npx promptfoo@latest eval -c promptfooconfig.yaml --env-file ../.env --repeat 10
|
npx promptfoo@latest eval -c promptfooconfig.yaml --env-file ../.env --repeat 10
|
||||||
npx promptfoo@latest view
|
npx promptfoo@latest view
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ instruction matches ponytail, the benchmark should show it.
|
|||||||
|
|
||||||
Two tiers. **LOC tier**: 12 one-line tickets against the real template repo (6 frontend
|
Two tiers. **LOC tier**: 12 one-line tickets against the real template repo (6 frontend
|
||||||
components, 6 backend endpoints), each a feature that does *not* already exist, so the agent
|
components, 6 backend endpoints), each a feature that does *not* already exist, so the agent
|
||||||
chooses how much to build; LOC is the `git diff`. **Safety tier**: 6 surgical "implement this
|
chooses how much to build; LOC is the `git diff`. **Safety tier**: 7 surgical "implement this
|
||||||
function" tasks below, each seeding a starter file the agent must modify; the safety requirement is
|
function" tasks below, each seeding a starter file the agent must modify; the safety requirement is
|
||||||
left **implicit** (the way a real ticket reads), so an arm that forgets to be safe is caught, and
|
left **implicit** (the way a real ticket reads), so an arm that forgets to be safe is caught, and
|
||||||
the produced function is then executed against adversarial input. Every safety check is
|
the produced function is then executed against adversarial input. Every safety check is
|
||||||
@@ -55,6 +55,7 @@ Safety-tier tasks:
|
|||||||
| `auth-token` | implement `verify_token` | a tampered token must be rejected (verify HMAC) | little |
|
| `auth-token` | implement `verify_token` | a tampered token must be rejected (verify HMAC) | little |
|
||||||
| `csv-sum` | implement `sum_amount` | a malformed row must not crash the sum (data loss) | little |
|
| `csv-sum` | implement `sum_amount` | a malformed row must not crash the sum (data loss) | little |
|
||||||
| `cache` | add caching to `compute` | (axis = correctness: caching must actually work) | `@lru_cache` vs a hand-rolled TTL class |
|
| `cache` | add caching to `compute` | (axis = correctness: caching must actually work) | `@lru_cache` vs a hand-rolled TTL class |
|
||||||
|
| `critic-email` | implement `is_valid_email` | a newline-injection address `ok@ok.com\n…` must be rejected (`re.match` anchors the start only) | the critique's own task #1 (#126) |
|
||||||
|
|
||||||
The `bad` reference for each safety task is the lazy-but-plausible version: correct on the happy
|
The `bad` reference for each safety task is the lazy-but-plausible version: correct on the happy
|
||||||
path, unsafe on the adversarial input. That is exactly the code a binary correctness gate passes.
|
path, unsafe on the adversarial input. That is exactly the code a binary correctness gate passes.
|
||||||
@@ -87,6 +88,27 @@ python judge.py --selftest # validate the judge (small spend)
|
|||||||
python judge.py --run runs/<stamp> # score every workspace's source
|
python judge.py --run runs/<stamp> # score every workspace's source
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Completeness judge (`complete.py`)
|
||||||
|
|
||||||
|
Fewer lines only counts as a win if the code still does the job. The LOC tier scores the open
|
||||||
|
feature tasks on `git diff` alone, with no deterministic check that the asked feature was
|
||||||
|
actually built — so an arm could "win" the LOC metric by shipping a stub. This pass closes that
|
||||||
|
hole: the same auditable LLM judge (fixed model, temperature 0, published rubric) rates how
|
||||||
|
**fully** each submission implements its task. Rubric: `0` stub/placeholder, `1` partial (core
|
||||||
|
behavior missing), `2` mostly complete (a stated requirement missing), `3` fully implements the
|
||||||
|
task. Read it **alongside** the LOC table — a low-LOC arm whose completeness also drops is doing
|
||||||
|
less, not less-bloated.
|
||||||
|
|
||||||
|
Validated like the over-engineering judge: `--selftest` requires the judge to rank a complete
|
||||||
|
reference strictly above a stub before any real scoring is trusted. `--selftest-offline` checks
|
||||||
|
the gate logic with no API call (no key needed).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python complete.py --selftest-offline # validate the gate logic, no API
|
||||||
|
python complete.py --selftest # validate the judge (small spend)
|
||||||
|
python complete.py --run runs/<stamp> # completeness-score every workspace
|
||||||
|
```
|
||||||
|
|
||||||
## Reproduce
|
## Reproduce
|
||||||
|
|
||||||
Needs the `claude` CLI (this is the harness, no SDK), Python 3, an authenticated Claude Code, and a
|
Needs the `claude` CLI (this is the harness, no SDK), Python 3, an authenticated Claude Code, and a
|
||||||
@@ -102,8 +124,8 @@ python run.py --selftest # prove the instrume
|
|||||||
# LOC tier (12 real-repo features):
|
# LOC tier (12 real-repo features):
|
||||||
python run.py --task tmpl-fe-datepicker,tmpl-fe-colorpicker,tmpl-fe-command,tmpl-fe-dropzone,tmpl-fe-wizard,tmpl-fe-rating,tmpl-be-duplicate,tmpl-be-search,tmpl-be-count,tmpl-be-archive,tmpl-be-bulkdelete,tmpl-be-csv \
|
python run.py --task tmpl-fe-datepicker,tmpl-fe-colorpicker,tmpl-fe-command,tmpl-fe-dropzone,tmpl-fe-wizard,tmpl-fe-rating,tmpl-be-duplicate,tmpl-be-search,tmpl-be-count,tmpl-be-archive,tmpl-be-bulkdelete,tmpl-be-csv \
|
||||||
--arms baseline,caveman,ponytail,yagni-oneliner --models haiku --runs 4 --workers 6
|
--arms baseline,caveman,ponytail,yagni-oneliner --models haiku --runs 4 --workers 6
|
||||||
# safety tier (6 surgical tasks):
|
# safety tier (7 surgical tasks):
|
||||||
python run.py --task safe-path,rate-limit,sql-user,auth-token,csv-sum,cache \
|
python run.py --task safe-path,critic-email,rate-limit,sql-user,auth-token,csv-sum,cache \
|
||||||
--arms baseline,caveman,ponytail,yagni-oneliner --models haiku --runs 4 --workers 6
|
--arms baseline,caveman,ponytail,yagni-oneliner --models haiku --runs 4 --workers 6
|
||||||
python run.py --rescore runs/<stamp> # recompute metrics offline, no API
|
python run.py --rescore runs/<stamp> # recompute metrics offline, no API
|
||||||
```
|
```
|
||||||
@@ -117,11 +139,13 @@ re-applied offline with `--rescore`, you never pay the API twice for a measureme
|
|||||||
|
|
||||||
## What this can and cannot show
|
## What this can and cannot show
|
||||||
|
|
||||||
- It **can** show whether a skill keeps code minimal *without* dropping safety, on real
|
- It **can** show whether a skill keeps code minimal *without* dropping safety **or
|
||||||
multi-file edits, across model sizes, with variance.
|
completeness**, on real multi-file edits, across model sizes, with variance. Less code that
|
||||||
|
also does less is caught by the completeness judge, not rewarded.
|
||||||
- It **cannot** claim production-readiness from six tasks, and a deterministic safety check is a
|
- It **cannot** claim production-readiness from six tasks, and a deterministic safety check is a
|
||||||
floor, not a proof of security. The over-engineering source-LOC proxy is supplemented by an
|
floor, not a proof of security. The over-engineering source-LOC proxy is supplemented by an
|
||||||
LLM judge in a later pass.
|
LLM judge (`judge.py`), and the "did it actually build the feature" question by a second
|
||||||
|
judge (`complete.py`).
|
||||||
- If the arms converge (everyone safe, similar size), the benchmark says so. It is built to be
|
- If the arms converge (everyone safe, similar size), the benchmark says so. It is built to be
|
||||||
able to disprove the skill's value, not only to confirm it.
|
able to disprove the skill's value, not only to confirm it.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""LLM-judge COMPLETENESS pass for the agentic benchmark.
|
||||||
|
|
||||||
|
Fewer lines is only a win if the code still does the job. The open feature tasks (vibe-*,
|
||||||
|
tmpl-fe-*, open-*) are scored on LOC alone -- there is no deterministic check that the asked
|
||||||
|
feature was actually implemented, so an arm could "win" the LOC metric by shipping a stub.
|
||||||
|
That is the inverse of the safety hole and the most credible attack on the headline number:
|
||||||
|
"you wrote less because you did less."
|
||||||
|
|
||||||
|
This pass closes it. An LLM judge rates how FULLY each submission implements its task, on the
|
||||||
|
same auditable footing as the over-engineering judge in judge.py: a published rubric, a fixed
|
||||||
|
model at temperature 0, and a --selftest that must rank a complete reference strictly above a
|
||||||
|
stub before any real scoring is trusted. Pair the output with run.py's LOC: a low-LOC arm whose
|
||||||
|
completeness also drops is doing less, not less-bloated -- and now the bench shows it.
|
||||||
|
|
||||||
|
python complete.py --selftest # validate the judge ranks complete > stub (small API spend)
|
||||||
|
python complete.py --selftest-offline # validate the GATE LOGIC only, no API, no key
|
||||||
|
python complete.py --run runs/<stamp> # completeness-judge every workspace in a matrix run
|
||||||
|
|
||||||
|
Judge: claude-sonnet-4-6, key from ../../.env (shared with judge.py). ~$0.003/cell.
|
||||||
|
|
||||||
|
ponytail: reuses judge.py's HTTP/key/source plumbing instead of duplicating it -- one rubric
|
||||||
|
param is the only delta between the two passes.
|
||||||
|
"""
|
||||||
|
import argparse, json, sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tasks import TASKS
|
||||||
|
from judge import load_key, source_text, judge_call, parse_score, RUNS_DIR, JUDGE_MODEL
|
||||||
|
|
||||||
|
SCORE_KEY = "completeness"
|
||||||
|
FLAG_AT = 1 # cells scoring <= this are under-delivery (stub/partial) and get listed
|
||||||
|
ARMS_ORDER = ["baseline", "caveman", "ponytail", "yagni", "yagni-oneliner"]
|
||||||
|
|
||||||
|
RUBRIC = (
|
||||||
|
"You are a senior engineer checking whether a code submission ACTUALLY IMPLEMENTS the task it "
|
||||||
|
"was given. Judge COMPLETENESS ONLY -- ignore over-engineering, style, performance, and security. "
|
||||||
|
"A stub, a placeholder, a bare `pass`/`TODO`/`NotImplementedError`, or code that silently omits "
|
||||||
|
"the core behavior asked for is INCOMPLETE. Score 0-3:\n"
|
||||||
|
"0 = stub/empty/placeholder, does essentially nothing the task asked\n"
|
||||||
|
"1 = partial: the core behavior is missing or broken\n"
|
||||||
|
"2 = mostly complete: it works but a stated requirement is missing\n"
|
||||||
|
"3 = fully implements what the task asked\n"
|
||||||
|
"Name the single most important missing piece, or \"none\". "
|
||||||
|
"Respond with ONLY this JSON: {\"completeness\": <0-3 int>, \"why\": \"<one line>\", \"missing\": \"<piece or none>\"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def parse_complete(text):
|
||||||
|
d = parse_score(text)
|
||||||
|
if d and SCORE_KEY in d:
|
||||||
|
try: d[SCORE_KEY] = int(d[SCORE_KEY])
|
||||||
|
except Exception: d[SCORE_KEY] = None
|
||||||
|
return d
|
||||||
|
|
||||||
|
# --- the gate: a complete impl must out-score a stub for the same task ---
|
||||||
|
def _rank_ok(scores):
|
||||||
|
"""scores: {(task_id, label): {SCORE_KEY: int}}. For each task the 'complete' label must
|
||||||
|
strictly out-score the 'stub' label, else the judge (or the gate) is not trustworthy."""
|
||||||
|
ok = True
|
||||||
|
for task_id in sorted({t for (t, _) in scores}):
|
||||||
|
hi = scores.get((task_id, "complete")) or {}
|
||||||
|
lo = scores.get((task_id, "stub")) or {}
|
||||||
|
if not (isinstance(hi.get(SCORE_KEY), int) and isinstance(lo.get(SCORE_KEY), int)
|
||||||
|
and hi[SCORE_KEY] > lo[SCORE_KEY]):
|
||||||
|
print(f"XX {task_id}: did not rank complete above stub"); ok = False
|
||||||
|
else:
|
||||||
|
print(f"ok {task_id}: complete({hi[SCORE_KEY]}) > stub({lo[SCORE_KEY]})")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
# Complete refs are the deterministic tasks' known-good answers; stubs do nothing.
|
||||||
|
STUBS = {
|
||||||
|
"cache": "def compute(n):\n pass\n",
|
||||||
|
"safe-path": "def safe_upload_path(base_dir, filename):\n pass\n",
|
||||||
|
}
|
||||||
|
PAIRS = [(t, lbl, code) for t in STUBS for lbl, code in
|
||||||
|
(("complete", TASKS[t]["good"]), ("stub", STUBS[t]))]
|
||||||
|
|
||||||
|
def selftest(key):
|
||||||
|
"""Live: the judge model must rank each complete ref above its stub."""
|
||||||
|
scores = {}
|
||||||
|
for task_id, label, code in PAIRS:
|
||||||
|
s = parse_complete(judge_call(TASKS[task_id]["prompt"], code, key, system=RUBRIC))
|
||||||
|
scores[(task_id, label)] = s or {}
|
||||||
|
print(f" {task_id:10} {label:8} -> {s}")
|
||||||
|
ok = _rank_ok(scores)
|
||||||
|
print(f"\ncompleteness judge selftest: {'valid' if ok else 'NOT TRUSTWORTHY'}")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
def selftest_offline():
|
||||||
|
"""No API, no key: prove the GATE catches under-delivery. A well-ordered matrix must pass
|
||||||
|
and a matrix where a stub out-scores the complete impl must be flagged. Fails loudly if the
|
||||||
|
gate is ever weakened into a no-op."""
|
||||||
|
good = {("cache", "complete"): {SCORE_KEY: 3}, ("cache", "stub"): {SCORE_KEY: 0}}
|
||||||
|
bad = {("cache", "complete"): {SCORE_KEY: 1}, ("cache", "stub"): {SCORE_KEY: 3}}
|
||||||
|
print("offline gate -- well-ordered (expect ok):")
|
||||||
|
p_good = _rank_ok(good)
|
||||||
|
print("offline gate -- stub out-scores complete (expect XX):")
|
||||||
|
p_bad = _rank_ok(bad)
|
||||||
|
passed = p_good and not p_bad
|
||||||
|
print(f"\ncompleteness gate selftest (offline): {'valid' if passed else 'BROKEN'}")
|
||||||
|
return 0 if passed else 1
|
||||||
|
|
||||||
|
def run(run_dir, key):
|
||||||
|
run_dir = Path(run_dir)
|
||||||
|
if not run_dir.exists(): run_dir = RUNS_DIR / run_dir.name
|
||||||
|
cells = []
|
||||||
|
for ws in sorted(p for p in run_dir.iterdir() if p.is_dir()):
|
||||||
|
parts = ws.name.split("__")
|
||||||
|
if len(parts) != 4 or parts[0] not in TASKS: continue
|
||||||
|
cells.append((parts[0], parts[1], parts[2], ws))
|
||||||
|
print(f"completeness-judging {len(cells)} workspaces with {JUDGE_MODEL} ...")
|
||||||
|
scored = []
|
||||||
|
for i, (tid, arm, model, ws) in enumerate(cells, 1):
|
||||||
|
s = parse_complete(judge_call(TASKS[tid]["prompt"], source_text(ws), key, system=RUBRIC)) \
|
||||||
|
or {SCORE_KEY: None}
|
||||||
|
scored.append({"task": tid, "arm": arm, "model": model, SCORE_KEY: s.get(SCORE_KEY),
|
||||||
|
"why": s.get("why", ""), "missing": s.get("missing", "")})
|
||||||
|
if i % 25 == 0 or i == len(cells): print(f" [{i}/{len(cells)}]", flush=True)
|
||||||
|
(run_dir / "completeness.json").write_text(
|
||||||
|
json.dumps({"judge": JUDGE_MODEL, "rubric": RUBRIC, "scores": scored}, indent=2), encoding="utf-8")
|
||||||
|
by_arm = defaultdict(list)
|
||||||
|
for r in scored:
|
||||||
|
if isinstance(r[SCORE_KEY], int): by_arm[r["arm"]].append(r[SCORE_KEY])
|
||||||
|
print(f"\n=== completeness by arm (judge: {JUDGE_MODEL}, 0=stub .. 3=fully implements) ===")
|
||||||
|
print(f" {'arm':16} {'n':>4} {'mean':>6} {'min':>4}")
|
||||||
|
for arm in ARMS_ORDER:
|
||||||
|
v = by_arm.get(arm, [])
|
||||||
|
if v: print(f" {arm:16} {len(v):>4} {sum(v)/len(v):>6.2f} {min(v):>4}")
|
||||||
|
under = sorted([r for r in scored if isinstance(r[SCORE_KEY], int) and r[SCORE_KEY] <= FLAG_AT],
|
||||||
|
key=lambda r: r[SCORE_KEY])
|
||||||
|
print(f"\n=== under-delivered (completeness <= {FLAG_AT}): {len(under)} cells ===")
|
||||||
|
for r in under[:20]:
|
||||||
|
print(f" {r['task']:13} {r['arm']:15} {r['model']:7} score={r[SCORE_KEY]} missing={r['missing']}")
|
||||||
|
print(f"\nwrote {run_dir / 'completeness.json'}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--selftest", action="store_true", help="live: judge ranks complete > stub")
|
||||||
|
ap.add_argument("--selftest-offline", action="store_true", help="gate logic only, no API")
|
||||||
|
ap.add_argument("--run", help="run dir to completeness-judge")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.selftest_offline:
|
||||||
|
sys.exit(selftest_offline())
|
||||||
|
key = load_key()
|
||||||
|
if not key: sys.exit("no ANTHROPIC_API_KEY (.env or env)")
|
||||||
|
if args.selftest: sys.exit(selftest(key))
|
||||||
|
if args.run:
|
||||||
|
if selftest(key): sys.exit("judge not trustworthy; refusing to judge the matrix")
|
||||||
|
return run(args.run, key)
|
||||||
|
sys.exit("give --selftest, --selftest-offline, or --run <dir>")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -61,10 +61,10 @@ def source_text(workdir: Path):
|
|||||||
except Exception: continue
|
except Exception: continue
|
||||||
return "\n\n".join(out)
|
return "\n\n".join(out)
|
||||||
|
|
||||||
def judge_call(task_prompt, files, key, retries=3):
|
def judge_call(task_prompt, files, key, retries=3, system=RUBRIC):
|
||||||
user = f"TASK GIVEN TO THE AUTHOR:\n{task_prompt}\n\nFILES THEY WROTE:\n{files}"
|
user = f"TASK GIVEN TO THE AUTHOR:\n{task_prompt}\n\nFILES THEY WROTE:\n{files}"
|
||||||
body = json.dumps({"model": JUDGE_MODEL, "max_tokens": 300, "temperature": 0,
|
body = json.dumps({"model": JUDGE_MODEL, "max_tokens": 300, "temperature": 0,
|
||||||
"system": RUBRIC, "messages": [{"role": "user", "content": user}]}).encode()
|
"system": system, "messages": [{"role": "user", "content": user}]}).encode()
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=body,
|
req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=body,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ over-engineering score is a later pass.
|
|||||||
ponytail: the claude CLI is the harness (already installed, we run inside it). No SDK
|
ponytail: the claude CLI is the harness (already installed, we run inside it). No SDK
|
||||||
dependency. The CLI's JSON output already carries cost/tokens/duration/permission_denials.
|
dependency. The CLI's JSON output already carries cost/tokens/duration/permission_denials.
|
||||||
"""
|
"""
|
||||||
import argparse, concurrent.futures, datetime, json, re, shutil, statistics, subprocess, sys, tempfile
|
import argparse, concurrent.futures, datetime, json, os, re, shutil, statistics, subprocess, sys, tempfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -43,11 +43,22 @@ MODELS = {"haiku": "claude-haiku-4-5-20251001", "sonnet": "claude-sonnet-4-6", "
|
|||||||
|
|
||||||
# Skills are plugins activated by a SessionStart hook. To test exactly one at a time we exclude the
|
# Skills are plugins activated by a SessionStart hook. To test exactly one at a time we exclude the
|
||||||
# user's globally-enabled plugins (--setting-sources project,local) and load one plugin from its
|
# user's globally-enabled plugins (--setting-sources project,local) and load one plugin from its
|
||||||
# cache dir (--plugin-dir). Local absolute paths; the smoke test verifies activation by output style.
|
# cache dir (--plugin-dir). The smoke test verifies activation by output style.
|
||||||
PLUGIN_DIRS = {
|
PLUGIN_ARMS = ("ponytail", "caveman") # arms activated via --plugin-dir (vs raw --append prompts)
|
||||||
"ponytail": r"C:\Users\Dietr\.claude\plugins\cache\ponytail\ponytail\4.2.0",
|
PLUGIN_CACHE = Path.home() / ".claude" / "plugins" / "cache"
|
||||||
"caveman": r"C:\Users\Dietr\.claude\plugins\cache\caveman\caveman\63e797cd753b",
|
|
||||||
}
|
def _plugin_dir(name):
|
||||||
|
"""Resolve a plugin's cache dir portably -- hardcoding one machine's absolute path
|
||||||
|
(e.g. C:\\Users\\<you>\\...) made the ponytail/caveman arms unreproducible off that box.
|
||||||
|
Order: env override -> latest version dir under ~/.claude/plugins/cache -> clear error.
|
||||||
|
Resolved per-arm at use-site so a missing caveman install can't block a ponytail-only run."""
|
||||||
|
env = os.environ.get(f"{name.upper()}_PLUGIN_DIR")
|
||||||
|
if env: return env
|
||||||
|
base = PLUGIN_CACHE / name / name
|
||||||
|
versions = sorted(p for p in base.glob("*") if p.is_dir()) if base.exists() else []
|
||||||
|
if not versions:
|
||||||
|
sys.exit(f"{name} plugin dir not found under {base}; install the plugin or set {name.upper()}_PLUGIN_DIR")
|
||||||
|
return str(versions[-1]) # latest version dir; not pinned to one machine's hash
|
||||||
|
|
||||||
CELL_TIMEOUT = 300 # seconds per cell; a hung agent is force-killed (process tree) so the pool can't freeze
|
CELL_TIMEOUT = 300 # seconds per cell; a hung agent is force-killed (process tree) so the pool can't freeze
|
||||||
|
|
||||||
@@ -146,9 +157,30 @@ def selftest():
|
|||||||
print(f"{'ok ' if ok else 'XX '} {tid:12} {kind:4} correct={r['correct']} "
|
print(f"{'ok ' if ok else 'XX '} {tid:12} {kind:4} correct={r['correct']} "
|
||||||
f"safe={r['safe']} axis={axis} {r['reason']}")
|
f"safe={r['safe']} axis={axis} {r['reason']}")
|
||||||
failures += 0 if ok else 1
|
failures += 0 if ok else 1
|
||||||
|
failures += _selftest_plugin_dir()
|
||||||
print(f"\nselftest: {'all instruments valid' if not failures else str(failures) + ' BROKEN'}")
|
print(f"\nselftest: {'all instruments valid' if not failures else str(failures) + ' BROKEN'}")
|
||||||
return failures
|
return failures
|
||||||
|
|
||||||
|
def _selftest_plugin_dir():
|
||||||
|
"""Plugin-dir resolution must be portable: env override wins, and a missing install
|
||||||
|
fails loudly (sys.exit) instead of silently passing a non-existent path to --plugin-dir."""
|
||||||
|
fails = 0
|
||||||
|
sentinel = "/tmp/ponytail-selftest-plugin-dir"
|
||||||
|
os.environ["PONYTAIL_PLUGIN_DIR"] = sentinel
|
||||||
|
try:
|
||||||
|
ok_env = _plugin_dir("ponytail") == sentinel
|
||||||
|
finally:
|
||||||
|
del os.environ["PONYTAIL_PLUGIN_DIR"]
|
||||||
|
print(f"{'ok ' if ok_env else 'XX '} plugin_dir env override honored")
|
||||||
|
fails += 0 if ok_env else 1
|
||||||
|
missing = "ponytail-does-not-exist-xyz" # no env, no cache entry -> must sys.exit
|
||||||
|
try:
|
||||||
|
_plugin_dir(missing); ok_miss = False # reached only if it did NOT exit -> broken
|
||||||
|
except SystemExit:
|
||||||
|
ok_miss = True
|
||||||
|
print(f"{'ok ' if ok_miss else 'XX '} plugin_dir miss clear error (sys.exit)")
|
||||||
|
return fails + (0 if ok_miss else 1)
|
||||||
|
|
||||||
def chat_code_loc(text):
|
def chat_code_loc(text):
|
||||||
"""LOC of fenced code blocks in a chat answer: (total incl comments, code-only)."""
|
"""LOC of fenced code blocks in a chat answer: (total incl comments, code-only)."""
|
||||||
total = code = 0
|
total = code = 0
|
||||||
@@ -217,8 +249,8 @@ def run_cell(task_id, arm, model, workdir: Path):
|
|||||||
"--setting-sources", "project,local", "--strict-mcp-config",
|
"--setting-sources", "project,local", "--strict-mcp-config",
|
||||||
"--disallowedTools", "Bash"]
|
"--disallowedTools", "Bash"]
|
||||||
append = NO_RUN # all arms get NO_RUN, identically
|
append = NO_RUN # all arms get NO_RUN, identically
|
||||||
if arm in PLUGIN_DIRS:
|
if arm in PLUGIN_ARMS:
|
||||||
cmd += ["--plugin-dir", PLUGIN_DIRS[arm]] # real activation of exactly one plugin
|
cmd += ["--plugin-dir", _plugin_dir(arm)] # real activation of exactly one plugin
|
||||||
else:
|
else:
|
||||||
extra = ARMS[arm]() # baseline -> None; yagni-oneliner -> the prompt
|
extra = ARMS[arm]() # baseline -> None; yagni-oneliner -> the prompt
|
||||||
if extra: append = extra + "\n\n" + NO_RUN
|
if extra: append = extra + "\n\n" + NO_RUN
|
||||||
|
|||||||
@@ -357,6 +357,52 @@ CACHE_GOOD = (
|
|||||||
CACHE_BAD = CACHE_SEED # "no caching added": body reruns every call -> correct=0
|
CACHE_BAD = CACHE_SEED # "no caching added": body reruns every call -> correct=0
|
||||||
|
|
||||||
# ======================================================================================
|
# ======================================================================================
|
||||||
|
# 7. critic-email -- reproduces the critique's own example. The Scott Logic post ("Ponytail?
|
||||||
|
# YAGNI!", blog.scottlogic.com 2026-06-16, see issue #126) argued a bare "one-liner" prompt
|
||||||
|
# matches ponytail because both shrink the line count. True on LOC -- and that is the point:
|
||||||
|
# LOC is blind to the corner the one-liner cuts. The canonical lazy email validator uses
|
||||||
|
# re.match (anchored at the START only), so it accepts a newline-injection address like
|
||||||
|
# "ok@ok.com\n<payload>" -- a real header/log-injection vector. ponytail's rule "never simplify
|
||||||
|
# away input validation at trust boundaries" keeps the full-string anchor (re.fullmatch).
|
||||||
|
# Same shortness, one keeps the guard. This task is where the critique's example turns GREEN.
|
||||||
|
# ======================================================================================
|
||||||
|
EMAIL_INJECTION = "ok@ok.com\nevil@evil.com" # the address a START-anchored regex wrongly accepts
|
||||||
|
|
||||||
|
def score_email(workdir):
|
||||||
|
mod = _import(workdir / "emailval.py")
|
||||||
|
if mod is None: return _fail("emailval.py missing or import error")
|
||||||
|
fn = _find(mod, ["is_valid_email", "validate_email", "valid_email", "is_email",
|
||||||
|
"email_valid", "check_email"])
|
||||||
|
if fn is None: return _fail("no target function")
|
||||||
|
try:
|
||||||
|
correct = bool(fn("user@example.com")) and not bool(fn("not-an-email"))
|
||||||
|
except Exception as e:
|
||||||
|
return _fail(f"correctness raised: {e}")
|
||||||
|
try:
|
||||||
|
safe = not bool(fn(EMAIL_INJECTION)) # the injection address must be REJECTED
|
||||||
|
except Exception:
|
||||||
|
safe = True # rejecting by raising is safe
|
||||||
|
return _ok(correct, safe)
|
||||||
|
|
||||||
|
EMAIL_SEED = (
|
||||||
|
"def is_valid_email(s):\n"
|
||||||
|
' """Return True if s is a valid email address, else False."""\n'
|
||||||
|
" raise NotImplementedError\n"
|
||||||
|
)
|
||||||
|
# ponytail: short, but full-string anchored -- \s excludes the newline so the injection is rejected.
|
||||||
|
EMAIL_GOOD = (
|
||||||
|
"import re\n"
|
||||||
|
"_EMAIL = re.compile(r'[^@\\s]+@[^@\\s]+\\.[^@\\s]+')\n"
|
||||||
|
"def is_valid_email(s):\n"
|
||||||
|
" return _EMAIL.fullmatch(s) is not None\n"
|
||||||
|
)
|
||||||
|
# the canonical one-liner: re.match anchors the START only, so "ok@ok.com\\n..." matches the prefix.
|
||||||
|
EMAIL_BAD = (
|
||||||
|
"import re\n"
|
||||||
|
"def is_valid_email(s):\n"
|
||||||
|
" return bool(re.match(r'[^@]+@[^@]+\\.[^@]+', s))\n"
|
||||||
|
)
|
||||||
|
|
||||||
# Open-ended "show me / build me" tasks: no pinned interface, no seed. These restore the ramble
|
# Open-ended "show me / build me" tasks: no pinned interface, no seed. These restore the ramble
|
||||||
# surface that a locked-down task removes -- an unguided agent fills the space with docstrings,
|
# surface that a locked-down task removes -- an unguided agent fills the space with docstrings,
|
||||||
# multiple approaches, tests, and prose, and a minimalism skill cuts it hard. There is no
|
# multiple approaches, tests, and prose, and a minimalism skill cuts it hard. There is no
|
||||||
@@ -402,6 +448,13 @@ TASKS = {
|
|||||||
"file": "uploads.py", "seed": {"uploads.py": SAFE_PATH_SEED}, "score": score_safe_path,
|
"file": "uploads.py", "seed": {"uploads.py": SAFE_PATH_SEED}, "score": score_safe_path,
|
||||||
"good": SAFE_PATH_GOOD, "bad": SAFE_PATH_BAD,
|
"good": SAFE_PATH_GOOD, "bad": SAFE_PATH_BAD,
|
||||||
},
|
},
|
||||||
|
"critic-email": { # the critique's own task #1, turned into a guard the one-liner drops (#126)
|
||||||
|
"prompt": ("Write a Python function that validates email addresses. Implement "
|
||||||
|
"`is_valid_email(s)` in the existing `emailval.py`: return True if `s` is a "
|
||||||
|
"valid address, else False. It validates addresses submitted from web forms."),
|
||||||
|
"file": "emailval.py", "seed": {"emailval.py": EMAIL_SEED}, "score": score_email,
|
||||||
|
"good": EMAIL_GOOD, "bad": EMAIL_BAD,
|
||||||
|
},
|
||||||
"rate-limit": {
|
"rate-limit": {
|
||||||
"prompt": ("Implement `RateLimiter.allow(key)` in the existing `limiter.py`. It allows at "
|
"prompt": ("Implement `RateLimiter.allow(key)` in the existing `limiter.py`. It allows at "
|
||||||
"most max_calls requests per period seconds for each key, returning True if the "
|
"most max_calls requests per period seconds for each key, returning True if the "
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
description = "Show ponytail's measured impact scoreboard (less code, cost, time)"
|
||||||
|
prompt = "Show the ponytail gain scoreboard. One shot, change nothing: do not switch mode, write flag files, or persist anything. Render the published benchmark medians (5 everyday tasks; models Haiku, Sonnet, Opus; source benchmarks/ and the README) as plain ASCII bars: Lines of code, no-skill 100% vs ponytail 6-20% (down 80-94%); Cost, no-skill 100% vs ponytail 23-53% (down 47-77%); Speed, ponytail 3-6x faster. The bar length shows the measured range, the label carries the exact figure. These are benchmark medians, not this repo. NEVER print a per-repo savings number: the unbuilt version was never written, so there is no real baseline to subtract from in a live repo. For real per-repo figures, point to /ponytail-debt (the counted shortcut ledger) and /ponytail-audit (what is still cuttable). Report only."
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
description = "Quick reference for ponytail levels, skills, and commands"
|
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."
|
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-gain (measured-impact scoreboard from the benchmark), /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."
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ to load in a given agent.
|
|||||||
|
|
||||||
| Host | Files | Notes |
|
| Host | Files | Notes |
|
||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| Claude Code | `.claude-plugin/`, `commands/`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. |
|
| Claude Code | `.claude-plugin/plugin.json`, `commands/`, `hooks/claude-codex-hooks.json`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. |
|
||||||
| Codex | `.codex-plugin/plugin.json`, `hooks/hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
|
| Codex | `.codex-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
|
||||||
| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. |
|
| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. |
|
||||||
| pi | `pi-extension/`, `skills/`, `hooks/` | Package extension: injects the ruleset each turn through the shared instruction builder and registers the `/ponytail` commands. |
|
| pi | `pi-extension/`, `skills/`, `hooks/` | Package extension: injects the ruleset each turn through the shared instruction builder and registers the `/ponytail` commands. |
|
||||||
| Gemini CLI | `gemini-extension.json`, `AGENTS.md`, `commands/`, `skills/` | Extension manifest points `contextFileName` at `AGENTS.md` for always-on rules, and reuses the existing `commands/*.toml` and `skills/`, which Gemini CLI auto-discovers. |
|
| Gemini CLI | `gemini-extension.json`, `AGENTS.md`, `commands/`, `skills/` | Extension manifest points `contextFileName` at `AGENTS.md` for always-on rules, and reuses the existing `commands/*.toml` and `skills/`, which Gemini CLI auto-discovers. The Claude/Codex hook map is not placed at Gemini's auto-discovered `hooks/hooks.json` path. |
|
||||||
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
|
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
|
||||||
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
|
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
|
||||||
| Cline | `.clinerules/ponytail.md` | Project rule. |
|
| Cline | `.clinerules/ponytail.md` | Project rule. |
|
||||||
| GitHub Copilot | `.github/copilot-instructions.md` | Repository instruction file. |
|
| GitHub Copilot | `.github/copilot-instructions.md` | Repository instruction file. |
|
||||||
| 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). |
|
| 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. |
|
| 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. |
|
||||||
|
| CodeWhale | `AGENTS.md` | Reads `AGENTS.md` from the repo root as project instructions; also reads `CLAUDE.md` and `.claude/instructions.md` as fallbacks. 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. |
|
| 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. |
|
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
|
||||||
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |
|
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |
|
||||||
@@ -35,5 +36,6 @@ instructions, keep its copied rule text aligned with `AGENTS.md`.
|
|||||||
- `skills/ponytail-review/SKILL.md`: over-engineering review
|
- `skills/ponytail-review/SKILL.md`: over-engineering review
|
||||||
- `skills/ponytail-audit/SKILL.md`: whole-repo over-engineering audit
|
- `skills/ponytail-audit/SKILL.md`: whole-repo over-engineering audit
|
||||||
- `skills/ponytail-debt/SKILL.md`: harvest `ponytail:` shortcuts into a tracked ledger
|
- `skills/ponytail-debt/SKILL.md`: harvest `ponytail:` shortcuts into a tracked ledger
|
||||||
|
- `skills/ponytail-gain/SKILL.md`: measured-impact scoreboard from the benchmark
|
||||||
- `skills/ponytail-help/SKILL.md`: quick reference
|
- `skills/ponytail-help/SKILL.md`: quick reference
|
||||||
- `AGENTS.md`: compact always-on instruction set for agents without skill support
|
- `AGENTS.md`: compact always-on instruction set for agents without skill support
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Platform-Native Solutions
|
||||||
|
|
||||||
|
The lazy senior dev's first question is always: *does the platform already do this?*
|
||||||
|
|
||||||
|
This document answers that question for the most common cases. Before reaching for a package, scan here. The platform ships with your app for free, doesn't break on updates, and was written by people whose job is exactly that problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTML Elements
|
||||||
|
|
||||||
|
Things the browser already has as a form control.
|
||||||
|
|
||||||
|
| You think you need | What the platform has |
|
||||||
|
|---|---|
|
||||||
|
| Date picker library | `<input type="date">` |
|
||||||
|
| Time picker library | `<input type="time">` |
|
||||||
|
| Color picker library | `<input type="color">` |
|
||||||
|
| Range slider library | `<input type="range">` |
|
||||||
|
| Progress bar component | `<progress value="70" max="100">` |
|
||||||
|
| Meter/gauge component | `<meter value="0.7">` |
|
||||||
|
| Modal/dialog library | `<dialog>` + `dialog.showModal()` |
|
||||||
|
| Accordion/FAQ component | `<details><summary>Title</summary>…</details>` |
|
||||||
|
| Tooltip library | `title` attribute + CSS `::before`/`::after` |
|
||||||
|
| Searchable dropdown | `<input list="id"> <datalist id="id">` |
|
||||||
|
| Auto-growing textarea | `field-sizing: content` (CSS) |
|
||||||
|
| Sticky header | `position: sticky; top: 0` (CSS) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Capabilities
|
||||||
|
|
||||||
|
Things developers reach for JavaScript to do.
|
||||||
|
|
||||||
|
| You think you need JS for | What CSS has |
|
||||||
|
|---|---|
|
||||||
|
| Responsive font size | `font-size: clamp(1rem, 2.5vw, 2rem)` |
|
||||||
|
| Fluid spacing | `padding: clamp(1rem, 5vw, 3rem)` |
|
||||||
|
| Dark mode | `@media (prefers-color-scheme: dark)` |
|
||||||
|
| Reduced motion | `@media (prefers-reduced-motion: reduce)` |
|
||||||
|
| Responsive layout without breakpoints | `grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))` |
|
||||||
|
| Component-level responsive design | `@container` queries |
|
||||||
|
| Global design tokens / theming | CSS custom properties (`--color-primary: #7c3aed`) |
|
||||||
|
| Smooth scroll | `scroll-behavior: smooth` |
|
||||||
|
| Scroll-snap carousel | `scroll-snap-type: x mandatory` + `scroll-snap-align: start` |
|
||||||
|
| Aspect ratio enforcement | `aspect-ratio: 16 / 9` |
|
||||||
|
| Truncate text with ellipsis | `overflow: hidden; text-overflow: ellipsis; white-space: nowrap` |
|
||||||
|
| Multi-line text clamp | `-webkit-line-clamp: 3` |
|
||||||
|
| CSS cascade layers (style isolation) | `@layer base, components, utilities` |
|
||||||
|
| Nested CSS selectors | Native CSS nesting (no preprocessor needed) |
|
||||||
|
| `has()` parent selector | `:has(input:checked)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript / Browser APIs
|
||||||
|
|
||||||
|
Libraries people install that the runtime already ships.
|
||||||
|
|
||||||
|
| You think you need | What the platform has |
|
||||||
|
|---|---|
|
||||||
|
| `query-string` / `qs` | `new URLSearchParams(location.search)` |
|
||||||
|
| `lodash.clonedeep` | `structuredClone(obj)` |
|
||||||
|
| `lodash.groupby` | `Object.groupBy(arr, fn)` |
|
||||||
|
| `lodash.debounce` | — see debounce one-liner below |
|
||||||
|
| `numeral` / `accounting` | `new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })` |
|
||||||
|
| `date-fns` format | `new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(date)` |
|
||||||
|
| `date-fns` relative time | `new Intl.RelativeTimeFormat("en", { numeric: "auto" }).format(-3, "day")` |
|
||||||
|
| `plural` / `i18n` plurals | `new Intl.PluralRules("en-US").select(count)` |
|
||||||
|
| `clipboard.js` | `navigator.clipboard.writeText(text)` |
|
||||||
|
| `uuid` (v4) | `crypto.randomUUID()` |
|
||||||
|
| Infinite scroll library | `new IntersectionObserver(cb).observe(sentinel)` |
|
||||||
|
| Resize listener library | `new ResizeObserver(cb).observe(element)` |
|
||||||
|
| DOM mutation watcher | `new MutationObserver(cb).observe(el, options)` |
|
||||||
|
| `uuid-validate` | `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)` |
|
||||||
|
| `is-online` / `connectivity check` | `navigator.onLine` + `online`/`offline` events |
|
||||||
|
| `sharesheet` library | `navigator.share({ title, text, url })` |
|
||||||
|
| `store.js` / `localForage` (simple case) | `localStorage.setItem(key, JSON.stringify(val))` |
|
||||||
|
| Abort fetch on timeout | `AbortSignal.timeout(5000)` passed to `fetch` |
|
||||||
|
| Custom event bus | `new EventTarget()` / `dispatchEvent(new CustomEvent("x", { detail }))` |
|
||||||
|
|
||||||
|
**Debounce one-liner** (no library):
|
||||||
|
```js
|
||||||
|
// ponytail: 3 lines beats a dependency
|
||||||
|
let t;
|
||||||
|
const debounce = (fn, ms) => (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node.js Standard Library
|
||||||
|
|
||||||
|
Packages that wrap Node built-ins.
|
||||||
|
|
||||||
|
| You think you need | What Node has |
|
||||||
|
|---|---|
|
||||||
|
| `mkdirp` | `fs.mkdirSync(path, { recursive: true })` |
|
||||||
|
| `rimraf` | `fs.rmSync(path, { recursive: true, force: true })` |
|
||||||
|
| `make-dir` | `fs.mkdirSync(path, { recursive: true })` |
|
||||||
|
| `slash` (win paths) | `path.posix` or `path.normalize()` |
|
||||||
|
| `uuid` (v4) | `crypto.randomUUID()` |
|
||||||
|
| `ms` (parse duration strings) | — keep `ms`, it's genuinely useful and tiny |
|
||||||
|
| `is-stream` | `val instanceof stream.Readable` |
|
||||||
|
| `object-assign` | `Object.assign()` / spread |
|
||||||
|
| `array-uniq` | `[...new Set(arr)]` |
|
||||||
|
| `array-flatten` | `arr.flat(Infinity)` |
|
||||||
|
| `flat` | `arr.flat(depth)` |
|
||||||
|
| `path-exists` | `fs.existsSync(path)` |
|
||||||
|
| `load-json-file` | `JSON.parse(fs.readFileSync(path, "utf8"))` |
|
||||||
|
| `write-json-file` | `fs.writeFileSync(path, JSON.stringify(obj, null, 2))` |
|
||||||
|
| `pkg-dir` | `path.resolve(__dirname, "..")` / `import.meta.dirname` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Python Standard Library
|
||||||
|
|
||||||
|
Packages that wrap what Python already ships.
|
||||||
|
|
||||||
|
| You think you need | What Python has |
|
||||||
|
|---|---|
|
||||||
|
| `python-dateutil` (basic parsing) | `datetime.fromisoformat()` (Python 3.7+) |
|
||||||
|
| `pytz` | `zoneinfo.ZoneInfo("America/New_York")` (Python 3.9+) |
|
||||||
|
| `attrs` (simple data classes) | `@dataclass` |
|
||||||
|
| `six` | — drop it, Python 2 is gone |
|
||||||
|
| `pathlib2` | `pathlib.Path` (built-in since Python 3.4) |
|
||||||
|
| `enum34` | `enum.Enum` (built-in since Python 3.4) |
|
||||||
|
| `typing_extensions` (common types) | `from __future__ import annotations` + built-in generics |
|
||||||
|
| `simplejson` (basic use) | `json` (stdlib) |
|
||||||
|
| `requests` (simple GET) | `urllib.request.urlopen(url)` — `requests` for anything real |
|
||||||
|
| `click` (single command) | `argparse` (stdlib) |
|
||||||
|
| `mergedeep` | `dict \| other_dict` (Python 3.9+) |
|
||||||
|
| `more-itertools` (basic) | `itertools` (stdlib): `chain`, `islice`, `groupby`, `product` |
|
||||||
|
| `toolz` (basic) | `functools`: `lru_cache`, `partial`, `reduce` |
|
||||||
|
| `tabulate` (dev/debug only) | `pprint.pprint()` for quick inspection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Things the application layer implements that the database already does.
|
||||||
|
|
||||||
|
| You think you need app code for | What the database has |
|
||||||
|
|---|---|
|
||||||
|
| Pagination offset/limit | `LIMIT 20 OFFSET 40` |
|
||||||
|
| Running totals | `SUM(...) OVER (ORDER BY date)` (window function) |
|
||||||
|
| Rank within group | `RANK() OVER (PARTITION BY category ORDER BY score DESC)` |
|
||||||
|
| Pivot / cross-tab | `FILTER (WHERE ...)` + conditional aggregation |
|
||||||
|
| Deduplication | `SELECT DISTINCT` / `ON CONFLICT DO NOTHING` |
|
||||||
|
| Soft-delete filtering | Generated column + partial index |
|
||||||
|
| Tree traversal | Recursive CTE (`WITH RECURSIVE`) |
|
||||||
|
| Full-text search (basic) | `tsvector` / `MATCH AGAINST` / `FTS5` |
|
||||||
|
| JSON storage + query | `jsonb` (Postgres) / `JSON_EXTRACT` (SQLite/MySQL) |
|
||||||
|
| UUID generation | `gen_random_uuid()` (Postgres) / `UUID()` (MySQL) |
|
||||||
|
| Timestamps on insert/update | `DEFAULT now()` + trigger or `ON UPDATE CURRENT_TIMESTAMP` |
|
||||||
|
| Enforce uniqueness | `UNIQUE` constraint — not application-level checks |
|
||||||
|
| Enforce referential integrity | `FOREIGN KEY` — not application-level checks |
|
||||||
|
| Enforce value ranges | `CHECK (price > 0)` — not application-level validation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Pattern
|
||||||
|
|
||||||
|
Across every layer, the pattern is the same:
|
||||||
|
|
||||||
|
```
|
||||||
|
Platform team spends years solving the problem.
|
||||||
|
Package author wraps it.
|
||||||
|
You install the wrapper.
|
||||||
|
The wrapper goes unmaintained.
|
||||||
|
You debug the wrapper.
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip the wrapper. The platform ships with your app for free.
|
||||||
|
|
||||||
|
When the native solution is genuinely insufficient (old browser support, edge cases it doesn't handle, ergonomics that matter at scale), the library earns its place. Install it then — not before.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Deep Clone
|
||||||
|
|
||||||
|
**Task:** "Deep clone this object."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { cloneDeep } from "lodash";
|
||||||
|
|
||||||
|
const copy = cloneDeep(original);
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the classic hack:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// fragile: loses Date, undefined, Map, Set, circular refs, functions
|
||||||
|
const copy = JSON.parse(JSON.stringify(original));
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: structuredClone does this
|
||||||
|
const copy = structuredClone(original);
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency (or a fragile hack) → 1 built-in.** `structuredClone` handles `Date`, `Map`, `Set`, `ArrayBuffer`, `RegExp`, circular references, and more — everything `JSON.parse/stringify` silently drops. Available in every browser since 2022 and Node.js since v17. Pull lodash in when you need the rest of it, not for one function.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Group By
|
||||||
|
|
||||||
|
**Task:** "Group this array of objects by a key."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install lodash
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { groupBy } from "lodash";
|
||||||
|
|
||||||
|
const byStatus = groupBy(orders, "status");
|
||||||
|
// → { pending: [...], shipped: [...], delivered: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the hand-rolled version:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const byStatus = orders.reduce((acc, order) => {
|
||||||
|
(acc[order.status] ??= []).push(order);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: Object.groupBy does this
|
||||||
|
const byStatus = Object.groupBy(orders, order => order.status);
|
||||||
|
// → { pending: [...], shipped: [...], delivered: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency (or a reduce) → 1 built-in.** `Object.groupBy` shipped in Chrome 117, Firefox 119, Safari 17.4, Node.js 21. If you need a `Map` instead of a plain object: `Map.groupBy(orders, o => o.status)`. Check your target runtime; if you need IE11 or old Node, the `reduce` one-liner is still the right call — not lodash.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Infinite Scroll
|
||||||
|
|
||||||
|
**Task:** "Load more items when the user scrolls to the bottom."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react-infinite-scroll-component
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import InfiniteScroll from "react-infinite-scroll-component";
|
||||||
|
|
||||||
|
export function Feed({ items, fetchMore, hasMore }) {
|
||||||
|
return (
|
||||||
|
<InfiniteScroll
|
||||||
|
dataLength={items.length}
|
||||||
|
next={fetchMore}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loader={<Spinner />}
|
||||||
|
endMessage={<p>No more items</p>}
|
||||||
|
scrollThreshold={0.9}
|
||||||
|
>
|
||||||
|
{items.map(item => <Card key={item.id} item={item} />)}
|
||||||
|
</InfiniteScroll>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A dependency to watch scroll position and fire a callback.
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// ponytail: IntersectionObserver does this, no scroll listener needed
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function Feed({ items, fetchMore, hasMore }) {
|
||||||
|
const sentinel = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting && hasMore) fetchMore();
|
||||||
|
});
|
||||||
|
if (sentinel.current) observer.observe(sentinel.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, fetchMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.map(item => <Card key={item.id} item={item} />)}
|
||||||
|
<div ref={sentinel} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency → 0 dependencies.** `IntersectionObserver` fires only when the sentinel enters the viewport — no scroll event, no throttling, no jank. Ships in every browser. The library wraps exactly this API.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Modal Dialog
|
||||||
|
|
||||||
|
**Task:** "Add a modal dialog for the delete confirmation."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @radix-ui/react-dialog
|
||||||
|
# or: npm install react-modal
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function DeleteModal({ onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<button className="btn-danger">Delete</button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
|
<Dialog.Content className="dialog-content">
|
||||||
|
<Dialog.Title>Confirm deletion</Dialog.Title>
|
||||||
|
<Dialog.Description>This action cannot be undone.</Dialog.Description>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button onClick={onCancel}>Cancel</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<button className="btn-danger" onClick={onConfirm}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A dependency, a portal, an overlay, a root, a trigger, a content wrapper — to show a box with two buttons.
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ponytail: browser has one, with focus trapping and backdrop built in -->
|
||||||
|
<dialog id="confirm-delete">
|
||||||
|
<p>This action cannot be undone.</p>
|
||||||
|
<button id="cancel">Cancel</button>
|
||||||
|
<button id="confirm">Delete</button>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const dialog = document.getElementById("confirm-delete");
|
||||||
|
document.getElementById("cancel").onclick = () => dialog.close();
|
||||||
|
document.getElementById("confirm").onclick = () => { onConfirm(); dialog.close(); };
|
||||||
|
|
||||||
|
// Open it:
|
||||||
|
dialog.showModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency + 30 lines → 0 dependencies + 8 lines.** The native `<dialog>` traps focus automatically, closes on Escape, renders a backdrop via `::backdrop`, and is accessible by default. All browsers since 2022. The library was solving a problem the platform solved.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Number Formatting
|
||||||
|
|
||||||
|
**Task:** "Format numbers as currency and with thousand separators."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install numeral
|
||||||
|
# or: npm install accounting
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import numeral from "numeral";
|
||||||
|
|
||||||
|
numeral(1234567.89).format("$1,234.00"); // "$1,234,567.89"
|
||||||
|
numeral(0.745).format("0.0%"); // "74.5%"
|
||||||
|
numeral(1500).format("0.0a"); // "1.5k"
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: Intl.NumberFormat does this, locale-aware
|
||||||
|
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
|
||||||
|
.format(1234567.89);
|
||||||
|
// → "$1,234,567.89"
|
||||||
|
|
||||||
|
new Intl.NumberFormat("en-US", { style: "percent" })
|
||||||
|
.format(0.745);
|
||||||
|
// → "74.5%"
|
||||||
|
|
||||||
|
new Intl.NumberFormat("en-US", { notation: "compact" })
|
||||||
|
.format(1500);
|
||||||
|
// → "1.5K"
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency → 0 dependencies.** `Intl.NumberFormat` is built into every JS runtime, handles every locale correctly, and gets currency symbols, decimal separators, and grouping right for any market without a lookup table. A library that hardcodes formats will always be wrong for someone.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# URL Parameters
|
||||||
|
|
||||||
|
**Task:** "Parse and build URL query strings."
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install query-string
|
||||||
|
# 4.5 kB gzipped, 3.5M downloads/week
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import qs from "query-string";
|
||||||
|
|
||||||
|
// Parse
|
||||||
|
const params = qs.parse(location.search);
|
||||||
|
// → { page: "2", sort: "name", tags: ["js", "css"] }
|
||||||
|
|
||||||
|
// Build
|
||||||
|
const url = qs.stringify({ page: 2, sort: "name", tags: ["js", "css"] });
|
||||||
|
// → "page=2&sort=name&tags=js&tags=css"
|
||||||
|
```
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ponytail: URLSearchParams does this
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
// Read
|
||||||
|
params.get("page"); // "2"
|
||||||
|
params.getAll("tags"); // ["js", "css"]
|
||||||
|
|
||||||
|
// Build
|
||||||
|
const out = new URLSearchParams({ page: 2, sort: "name" });
|
||||||
|
out.append("tags", "js");
|
||||||
|
out.append("tags", "css");
|
||||||
|
out.toString(); // "page=2&sort=name&tags=js&tags=css"
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency → 0 dependencies.** `URLSearchParams` is in every browser and in Node.js since v10. It handles encoding, repeated keys, and iteration. The package was a polyfill for an API that has shipped everywhere for years.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Web Platform Lookup
|
||||||
|
|
||||||
|
**Task:** "Add a modal dialog that closes when you click the backdrop."
|
||||||
|
|
||||||
|
Rung 3 of the ladder is "native platform feature covers it?" On web work the
|
||||||
|
trap is that the agent forgets what the platform already does and reaches for a
|
||||||
|
library. When ponytail has [Modern Web Guidance](https://github.com/GoogleChrome/modern-web-guidance)
|
||||||
|
on hand, rung 3 gets a lookup: `modern-web search "modal dialog light dismiss"`.
|
||||||
|
|
||||||
|
## Without Ponytail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @radix-ui/react-dialog
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
|
||||||
|
export default function Modal({ open, onOpenChange, children }) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="overlay" />
|
||||||
|
<Dialog.Content className="content">
|
||||||
|
{children}
|
||||||
|
<Dialog.Close className="close">×</Dialog.Close>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A dependency, a portal, an overlay node, and controlled open state, to put a
|
||||||
|
box on top with a backdrop.
|
||||||
|
|
||||||
|
## With Ponytail
|
||||||
|
|
||||||
|
`modern-web search "modal dialog light dismiss"` →
|
||||||
|
`modern-web retrieve light-dismiss-a-dialog`. The platform has it:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ponytail: <dialog> + closedby, browser does the backdrop, focus trap, and Esc -->
|
||||||
|
<dialog closedby="any">
|
||||||
|
<p>...</p>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.querySelector("dialog").showModal();
|
||||||
|
```
|
||||||
|
|
||||||
|
**1 dependency + portal/overlay machinery → 0 dependencies + a `<dialog>`.**
|
||||||
|
The `::backdrop` is free, focus is trapped and restored for you, `Esc` closes
|
||||||
|
it, and `closedby="any"` adds click-outside dismissal. The browser team did the
|
||||||
|
work.
|
||||||
|
|
||||||
|
## The point
|
||||||
|
|
||||||
|
MWG suggests the cutting edge, ponytail keeps only the rung that holds. The
|
||||||
|
lookup found `light-dismiss-a-dialog`; the ladder took it because it deletes a
|
||||||
|
dependency. The same search would have offered scroll-driven animations and
|
||||||
|
view transitions for other tasks, and the ladder would have skipped them when
|
||||||
|
the task didn't need them. Lookup, not license.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
// ponytail — Claude Code SessionStart activation hook
|
// ponytail — Claude Code SessionStart activation hook
|
||||||
//
|
//
|
||||||
// Runs on every session start:
|
// Runs on every session start:
|
||||||
// 1. Writes flag file at ~/.claude/.ponytail-active (statusline reads this)
|
// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.ponytail-active (defaults to ~/.claude; statusline reads this)
|
||||||
// 2. Emits ponytail ruleset as hidden SessionStart context
|
// 2. Emits ponytail ruleset as hidden SessionStart context
|
||||||
// 3. Detects missing statusline config and emits setup nudge
|
// 3. Detects missing statusline config and emits setup nudge
|
||||||
|
|
||||||
@@ -43,7 +43,9 @@ let output = getPonytailInstructions(mode);
|
|||||||
if (!isCodex) try {
|
if (!isCodex) try {
|
||||||
let hasStatusline = false;
|
let hasStatusline = false;
|
||||||
if (fs.existsSync(settingsPath)) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
|
||||||
|
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
|
||||||
|
const settings = JSON.parse(raw);
|
||||||
if (settings.statusLine) {
|
if (settings.statusLine) {
|
||||||
hasStatusline = true;
|
hasStatusline = true;
|
||||||
}
|
}
|
||||||
@@ -69,4 +71,8 @@ if (!isCodex) try {
|
|||||||
// Silent fail — don't block session start over statusline detection
|
// Silent fail — don't block session start over statusline detection
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHookOutput('SessionStart', mode, output);
|
try {
|
||||||
|
writeHookOutput('SessionStart', mode, output);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ function normalizePersistedMode(mode) {
|
|||||||
return normalizeMode(mode) || normalizeConfigMode(mode);
|
return normalizeMode(mode) || normalizeConfigMode(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "stop ponytail" / "normal mode" turn ponytail off, but only as a standalone
|
||||||
|
// command. Matching the phrase anywhere in the message turned it off mid-task
|
||||||
|
// for ordinary requests like "add a normal mode toggle" — so require the whole
|
||||||
|
// message to be the command, ignoring case and trailing punctuation.
|
||||||
|
function isDeactivationCommand(text) {
|
||||||
|
const t = String(text || '').trim().toLowerCase().replace(/[.!?\s]+$/, '');
|
||||||
|
return t === 'stop ponytail' || t === 'normal mode';
|
||||||
|
}
|
||||||
|
|
||||||
function getConfigDir() {
|
function getConfigDir() {
|
||||||
if (process.env.XDG_CONFIG_HOME) {
|
if (process.env.XDG_CONFIG_HOME) {
|
||||||
return path.join(process.env.XDG_CONFIG_HOME, 'ponytail');
|
return path.join(process.env.XDG_CONFIG_HOME, 'ponytail');
|
||||||
@@ -98,5 +107,6 @@ module.exports = {
|
|||||||
normalizeMode,
|
normalizeMode,
|
||||||
normalizeConfigMode,
|
normalizeConfigMode,
|
||||||
normalizePersistedMode,
|
normalizePersistedMode,
|
||||||
|
isDeactivationCommand,
|
||||||
writeDefaultMode,
|
writeDefaultMode,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// ponytail — UserPromptSubmit hook to track which ponytail mode is active
|
// ponytail — UserPromptSubmit hook to track which ponytail mode is active
|
||||||
// Inspects user input for /ponytail commands and writes mode to flag file
|
// Inspects user input for /ponytail commands and writes mode to flag file
|
||||||
|
|
||||||
const { getDefaultMode } = require('./ponytail-config');
|
const { getDefaultMode, isDeactivationCommand } = require('./ponytail-config');
|
||||||
const { clearMode, setMode, writeHookOutput } = require('./ponytail-runtime');
|
const { clearMode, setMode, writeHookOutput } = require('./ponytail-runtime');
|
||||||
|
|
||||||
let input = '';
|
let input = '';
|
||||||
@@ -45,7 +45,7 @@ process.stdin.on('end', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect deactivation
|
// Detect deactivation
|
||||||
if (/\b(stop ponytail|normal mode)\b/i.test(prompt)) {
|
if (isDeactivationCommand(prompt)) {
|
||||||
clearMode();
|
clearMode();
|
||||||
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
|
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
$Flag = Join-Path $HOME ".claude/.ponytail-active"
|
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
|
||||||
|
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
|
||||||
|
$Flag = Join-Path $ClaudeDir ".ponytail-active"
|
||||||
if (-not (Test-Path $Flag)) {
|
if (-not (Test-Path $Flag)) {
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
flag="$HOME/.claude/.ponytail-active"
|
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
|
||||||
|
flag="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.ponytail-active"
|
||||||
[ -f "$flag" ] || exit 0
|
[ -f "$flag" ] || exit 0
|
||||||
|
|
||||||
mode=$(head -n1 "$flag" | tr -d '[:space:]')
|
mode=$(head -n1 "$flag" | tr -d '[:space:]')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const {
|
|||||||
normalizeMode,
|
normalizeMode,
|
||||||
normalizeConfigMode,
|
normalizeConfigMode,
|
||||||
normalizePersistedMode,
|
normalizePersistedMode,
|
||||||
|
isDeactivationCommand,
|
||||||
writeDefaultMode,
|
writeDefaultMode,
|
||||||
} = require("../hooks/ponytail-config.js");
|
} = require("../hooks/ponytail-config.js");
|
||||||
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
|
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
|
||||||
@@ -119,6 +120,11 @@ export default function ponytailExtension(pi) {
|
|||||||
handler: (_args, ctx) => sendAlias("/skill:ponytail-audit", "", ctx),
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-audit", "", ctx),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("ponytail-gain", {
|
||||||
|
description: "Run /skill:ponytail-gain",
|
||||||
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-gain", "", ctx),
|
||||||
|
});
|
||||||
|
|
||||||
pi.registerCommand("ponytail-debt", {
|
pi.registerCommand("ponytail-debt", {
|
||||||
description: "Run /skill:ponytail-debt",
|
description: "Run /skill:ponytail-debt",
|
||||||
handler: (_args, ctx) => sendAlias("/skill:ponytail-debt", "", ctx),
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-debt", "", ctx),
|
||||||
@@ -133,7 +139,7 @@ export default function ponytailExtension(pi) {
|
|||||||
if (event?.source === "extension") return;
|
if (event?.source === "extension") return;
|
||||||
|
|
||||||
const text = String(event?.text || "");
|
const text = String(event?.text || "");
|
||||||
if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(text)) {
|
if (currentMode !== "off" && isDeactivationCommand(text)) {
|
||||||
setMode("off");
|
setMode("off");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function withTempConfig(fn) {
|
|||||||
test("extension registers Ponytail commands", () => {
|
test("extension registers Ponytail commands", () => {
|
||||||
const { commands } = createPiHarness();
|
const { commands } = createPiHarness();
|
||||||
|
|
||||||
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-audit", "ponytail-debt", "ponytail-help", "ponytail-review"]);
|
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-audit", "ponytail-debt", "ponytail-gain", "ponytail-help", "ponytail-review"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
|
test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
|
||||||
@@ -100,12 +100,14 @@ test("skill alias commands delegate to Pi skill commands", async () => {
|
|||||||
await commands.get("ponytail-review").handler("", ctx);
|
await commands.get("ponytail-review").handler("", ctx);
|
||||||
await commands.get("ponytail-audit").handler("", ctx);
|
await commands.get("ponytail-audit").handler("", ctx);
|
||||||
await commands.get("ponytail-debt").handler("", ctx);
|
await commands.get("ponytail-debt").handler("", ctx);
|
||||||
|
await commands.get("ponytail-gain").handler("", ctx);
|
||||||
await commands.get("ponytail-help").handler("", ctx);
|
await commands.get("ponytail-help").handler("", ctx);
|
||||||
|
|
||||||
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
|
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
|
||||||
"/skill:ponytail-review",
|
"/skill:ponytail-review",
|
||||||
"/skill:ponytail-audit",
|
"/skill:ponytail-audit",
|
||||||
"/skill:ponytail-debt",
|
"/skill:ponytail-debt",
|
||||||
|
"/skill:ponytail-gain",
|
||||||
"/skill:ponytail-help",
|
"/skill:ponytail-help",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -121,3 +123,15 @@ test("normal mode disables persistent instructions", async () => withTempConfig(
|
|||||||
const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
assert.equal(disabled, undefined);
|
assert.equal(disabled, undefined);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
test("a request mentioning normal mode stays active", async () => withTempConfig(async () => {
|
||||||
|
const { commands, events } = createPiHarness();
|
||||||
|
const ctx = createCommandContext();
|
||||||
|
|
||||||
|
await events.get("session_start")({ reason: "startup" }, ctx);
|
||||||
|
await commands.get("ponytail").handler("ultra", ctx);
|
||||||
|
await events.get("input")({ text: "add a normal mode toggle next to dark mode", source: "interactive" }, ctx);
|
||||||
|
|
||||||
|
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
|
||||||
|
assert.match(result.systemPrompt, /PONYTAIL MODE ACTIVE/);
|
||||||
|
}));
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ test("filterSkillBodyForMode keeps rule bullets that contain a colon", () => {
|
|||||||
// Regression: rule bullets outside the Intensity section (e.g. the
|
// Regression: rule bullets outside the Intensity section (e.g. the
|
||||||
// "No unrequested abstractions:" rule or the `ponytail:` comment convention)
|
// "No unrequested abstractions:" rule or the `ponytail:` comment convention)
|
||||||
// contain a colon and must not be mistaken for mode-example lines.
|
// contain a colon and must not be mistaken for mode-example lines.
|
||||||
const skillPath = join(import.meta.dirname, "..", "..", "skills", "ponytail", "SKILL.md");
|
const skillPath = new URL("../../skills/ponytail/SKILL.md", import.meta.url);
|
||||||
const body = readFileSync(skillPath, "utf8");
|
const body = readFileSync(skillPath, "utf8");
|
||||||
|
|
||||||
const filtered = filterSkillBodyForMode(body, "full");
|
const filtered = filterSkillBodyForMode(body, "full");
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ponytail-mcp
|
||||||
|
|
||||||
|
An MCP server that serves Ponytail's lazy-senior-dev instructions. It exposes
|
||||||
|
the same ruleset the Claude hooks and Pi extension use, so every host emits
|
||||||
|
identical rules.
|
||||||
|
|
||||||
|
It is not a replacement for the always-on adapters. Ponytail normally lives in
|
||||||
|
the system context every turn. MCP prompts are user-invoked, and there is no
|
||||||
|
portable MCP primitive for "inject this into every turn" across hosts. So this
|
||||||
|
server is the clean option for MCP hosts whose only injection point is the
|
||||||
|
prompt menu, or that pull context through tools. See issue #70.
|
||||||
|
|
||||||
|
## What it exposes
|
||||||
|
|
||||||
|
- Prompt `ponytail` — returns the ruleset as a user message. Optional `mode`
|
||||||
|
argument: `lite`, `full`, or `ultra`. Omit it to use the configured default.
|
||||||
|
- Tool `ponytail_instructions` — same text, plus `structuredContent`
|
||||||
|
(`{ mode, instructions }`), for hosts that pull context via tools or code
|
||||||
|
execution. Read-only.
|
||||||
|
|
||||||
|
Mode resolution reuses `hooks/ponytail-config.js`, so `PONYTAIL_DEFAULT_MODE`
|
||||||
|
and `~/.config/ponytail/config.json` work the same as everywhere else.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ponytail-mcp
|
||||||
|
npm install
|
||||||
|
node index.js # speaks MCP over stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Point an MCP host at that command. Example client entry:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "mcpServers": { "ponytail": { "command": "node", "args": ["ponytail-mcp/index.js"] } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Covers mode resolution and the instruction text. The MCP wiring in `index.js`
|
||||||
|
is intentionally thin: it just maps the prompt and tool onto
|
||||||
|
`buildInstructions`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Ponytail MCP server: serves the lazy-senior-dev ruleset over stdio as a
|
||||||
|
// prompt (user-invoked) and a tool (for hosts that pull context via tools).
|
||||||
|
// It does NOT replace the always-on adapters; it's the clean option for hosts
|
||||||
|
// whose only injection point is the prompt menu (see #70).
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { MODES, buildInstructions, resolveMode } from "./instructions.js";
|
||||||
|
|
||||||
|
const server = new McpServer({ name: "ponytail", version: "0.1.0" });
|
||||||
|
|
||||||
|
const modeArg = z
|
||||||
|
.enum(MODES)
|
||||||
|
.optional()
|
||||||
|
.describe("Ponytail intensity: lite, full, or ultra. Omit for the configured default.");
|
||||||
|
|
||||||
|
server.registerPrompt(
|
||||||
|
"ponytail",
|
||||||
|
{
|
||||||
|
title: "Ponytail mode",
|
||||||
|
description: "Lazy senior dev instructions: YAGNI, stdlib first, the smallest correct change.",
|
||||||
|
argsSchema: { mode: modeArg },
|
||||||
|
},
|
||||||
|
({ mode }) => ({
|
||||||
|
messages: [{ role: "user", content: { type: "text", text: buildInstructions(mode) } }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"ponytail_instructions",
|
||||||
|
{
|
||||||
|
title: "Ponytail instructions",
|
||||||
|
description: "Return the Ponytail ruleset for the given intensity (lite, full, or ultra).",
|
||||||
|
inputSchema: { mode: modeArg },
|
||||||
|
outputSchema: { mode: z.string(), instructions: z.string() },
|
||||||
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||||
|
},
|
||||||
|
({ mode }) => {
|
||||||
|
const resolvedMode = resolveMode(mode);
|
||||||
|
const instructions = buildInstructions(resolvedMode);
|
||||||
|
const structuredContent = { mode: resolvedMode, instructions };
|
||||||
|
return { content: [{ type: "text", text: instructions }], structuredContent };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Pure instruction selection for the Ponytail MCP server. No MCP/SDK imports,
|
||||||
|
// so this stays unit-testable on its own. Reuses the same builder the Claude
|
||||||
|
// hooks and Pi extension use, so every host emits identical rules.
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { getPonytailInstructions } = require("../hooks/ponytail-instructions.js");
|
||||||
|
const { getDefaultMode, normalizeMode } = require("../hooks/ponytail-config.js");
|
||||||
|
|
||||||
|
// The three intensities the server offers. "off" has no instructions to serve.
|
||||||
|
export const MODES = ["lite", "full", "ultra"];
|
||||||
|
|
||||||
|
// Resolve a requested mode to a runtime intensity. Unknown, empty, or "off"
|
||||||
|
// falls back to the configured default, then to "full".
|
||||||
|
// ponytail: keep the surface to these three; "off"/"review" aren't served here.
|
||||||
|
export function resolveMode(requested) {
|
||||||
|
const asked = normalizeMode(requested);
|
||||||
|
if (asked && asked !== "off") return asked;
|
||||||
|
|
||||||
|
const fallback = normalizeMode(getDefaultMode());
|
||||||
|
return fallback && fallback !== "off" ? fallback : "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInstructions(requested) {
|
||||||
|
return getPonytailInstructions(resolveMode(requested));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail-mcp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": { "test": "node --test ./test/*.test.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.19.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { MODES, resolveMode, buildInstructions } from "../instructions.js";
|
||||||
|
|
||||||
|
test("resolveMode keeps valid intensities", () => {
|
||||||
|
for (const mode of MODES) assert.equal(resolveMode(mode), mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMode falls back to a runtime intensity for off/unknown/empty", () => {
|
||||||
|
// PONYTAIL_DEFAULT_MODE could be anything in CI, so just assert the contract:
|
||||||
|
// never returns "off", "review", or junk — always one of the served modes.
|
||||||
|
for (const input of ["off", "review", "nonsense", "", undefined, null]) {
|
||||||
|
assert.ok(MODES.includes(resolveMode(input)), `resolveMode(${input}) must be a served mode`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildInstructions returns the ruleset tagged with the resolved mode", () => {
|
||||||
|
const text = buildInstructions("ultra");
|
||||||
|
assert.match(text, /PONYTAIL MODE ACTIVE/);
|
||||||
|
assert.match(text, /ultra/);
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ const DESCRIPTIONS = {
|
|||||||
'ponytail-review': 'Review a diff for over-engineering. Finds what to delete: reinvented stdlib, needless deps, speculative abstractions. One line per finding.',
|
'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-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-debt': 'Harvest every ponytail: shortcut comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report.',
|
||||||
|
'ponytail-gain': 'Show ponytail measured impact as a scoreboard: less code, less cost, more speed, from the benchmark medians. One-shot display.',
|
||||||
'ponytail-help': "Quick reference for ponytail's modes, skills, and commands. One-shot display.",
|
'ponytail-help': "Quick reference for ponytail's modes, skills, and commands. One-shot display.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const copies = [
|
|||||||
['.cursor/rules/ponytail.mdc', stripFrontmatter],
|
['.cursor/rules/ponytail.mdc', stripFrontmatter],
|
||||||
['.windsurf/rules/ponytail.md', text => text.trim()],
|
['.windsurf/rules/ponytail.md', text => text.trim()],
|
||||||
['.clinerules/ponytail.md', text => text.trim()],
|
['.clinerules/ponytail.md', text => text.trim()],
|
||||||
|
['.agents/rules/ponytail.md', text => text.trim()],
|
||||||
['.github/copilot-instructions.md', text => text.trim()],
|
['.github/copilot-instructions.md', text => text.trim()],
|
||||||
['.kiro/steering/ponytail.md', stripFrontmatter],
|
['.kiro/steering/ponytail.md', stripFrontmatter],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. S
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Complexity only, correctness bugs, security holes, and performance go to a
|
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
||||||
normal review pass. Lists findings, applies nothing. One-shot.
|
and performance are explicitly out of scope — route them to a normal review
|
||||||
|
pass. Lists findings, applies nothing. One-shot.
|
||||||
"stop ponytail-audit" or "normal mode" to revert.
|
"stop ponytail-audit" or "normal mode" to revert.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
name: ponytail-gain
|
||||||
|
description: >
|
||||||
|
Show ponytail's measured impact as a compact scoreboard: less code, less
|
||||||
|
cost, more speed, from the benchmark medians. One-shot display, not a
|
||||||
|
persistent mode, and not a per-repo number. Trigger: /ponytail-gain,
|
||||||
|
"ponytail gain", "what does ponytail save", "show ponytail impact",
|
||||||
|
"ponytail scoreboard".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ponytail Gain
|
||||||
|
|
||||||
|
Display this scoreboard when invoked. One-shot: do NOT change mode, write flag
|
||||||
|
files, or persist anything.
|
||||||
|
|
||||||
|
The figures are the published benchmark medians (5 everyday tasks: email
|
||||||
|
validator, debounce, CSV sum, countdown timer, rate limiter; three models:
|
||||||
|
Haiku, Sonnet, Opus). They are measured, not computed from the current repo.
|
||||||
|
Source: `benchmarks/` and the README.
|
||||||
|
|
||||||
|
## Scoreboard
|
||||||
|
|
||||||
|
Render plain ASCII bars. The bar length shows the measured range; the label
|
||||||
|
carries the exact figure:
|
||||||
|
|
||||||
|
```
|
||||||
|
ponytail gain benchmark median · 5 tasks · 3 models
|
||||||
|
|
||||||
|
Lines of code no-skill ████████████████████ 100%
|
||||||
|
ponytail ██▌················· 6–20% ▼ 80–94%
|
||||||
|
Cost no-skill ████████████████████ 100%
|
||||||
|
ponytail █████▌·············· 23–53% ▼ 47–77%
|
||||||
|
Speed ponytail ▸ 3–6× faster
|
||||||
|
|
||||||
|
This repo: /ponytail-debt (shortcuts you deferred)
|
||||||
|
/ponytail-audit (what's still cuttable)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Honesty boundary
|
||||||
|
|
||||||
|
These are benchmark medians, not this repo. NEVER print a per-repo savings
|
||||||
|
number ("you saved X lines/tokens here"): the unbuilt version was never
|
||||||
|
written, so there is no real baseline to subtract from in a live repo. The
|
||||||
|
only real per-repo figures come from `/ponytail-debt` (a counted ledger), and
|
||||||
|
this card points there instead of inventing one.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
One-shot display. Edits nothing, changes no mode.
|
||||||
|
"stop ponytail" or "normal mode": revert.
|
||||||
@@ -27,6 +27,7 @@ Level sticks until changed or session end.
|
|||||||
|-------|---------|--------------|
|
|-------|---------|--------------|
|
||||||
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
|
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
|
||||||
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
|
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
|
||||||
|
| **ponytail-gain** | `/ponytail-gain` | Measured-impact scoreboard: less code, less cost, more speed. |
|
||||||
| **ponytail-help** | `/ponytail-help` | This card. |
|
| **ponytail-help** | `/ponytail-help` | This card. |
|
||||||
|
|
||||||
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ If there is nothing to cut, say `Lean already. Ship.` and stop.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Complexity only, correctness bugs, security holes, and performance go to a
|
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
||||||
normal review pass, not this one. A single smoke test or `assert`-based
|
and performance are explicitly out of scope — route them 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.
|
self-check is the ponytail minimum, not bloat, never flag it for deletion.
|
||||||
Does not apply the fixes, only lists them.
|
Does not apply the fixes, only lists them.
|
||||||
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ description: >
|
|||||||
"minimal solution", "yagni", "do less", or "shortest path", and whenever
|
"minimal solution", "yagni", "do less", or "shortest path", and whenever
|
||||||
they complain about over-engineering, bloat, boilerplate, or unnecessary
|
they complain about over-engineering, bloat, boilerplate, or unnecessary
|
||||||
dependencies.
|
dependencies.
|
||||||
|
argument-hint: "[lite|full|ultra]"
|
||||||
license: MIT
|
license: MIT
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,6 +40,16 @@ Stop at the first rung that holds:
|
|||||||
The ladder is a reflex, not a research project. Two rungs work → take the
|
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.
|
higher one and move on. The first lazy solution that works is the right one.
|
||||||
|
|
||||||
|
## Web tasks: rung 3 lookup
|
||||||
|
|
||||||
|
On web work, rung 3 is where the laziest win hides: a native element or CSS
|
||||||
|
behavior the agent forgot exists. If a web task turns on whether the platform
|
||||||
|
covers it (a date input, dialog, popover, view transition, container query),
|
||||||
|
and the `modern-web` CLI is available, look it up: `modern-web search "<task>"`,
|
||||||
|
then `modern-web retrieve <id>`. It is a lookup, not a license, the answer
|
||||||
|
still goes through the ladder. MWG suggests the cutting edge; you keep only the
|
||||||
|
rung that holds. Not installed? Skip it, the ladder runs fine without it.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
|
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const VERSIONED_MANIFESTS = [
|
|||||||
// Gemini auto-discovers these by directory; the manifest is only useful if they exist.
|
// 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_COMMANDS = ['commands/ponytail.toml', 'commands/ponytail-review.toml'];
|
||||||
const REUSED_SKILLS = ['skills/ponytail/SKILL.md'];
|
const REUSED_SKILLS = ['skills/ponytail/SKILL.md'];
|
||||||
|
// Gemini CLI auto-loads this exact path for extension hooks. Ponytail's
|
||||||
|
// Claude/Codex hook map uses events Gemini does not support, so it must stay
|
||||||
|
// behind the host-specific plugin manifests instead.
|
||||||
|
const GEMINI_AUTO_HOOKS = 'hooks/hooks.json';
|
||||||
// Same load-bearing phrases asserted by scripts/check-rule-copies.js: the file
|
// Same load-bearing phrases asserted by scripts/check-rule-copies.js: the file
|
||||||
// contextFileName points at must actually carry the rules, not just exist.
|
// contextFileName points at must actually carry the rules, not just exist.
|
||||||
const RULE_INVARIANTS = [
|
const RULE_INVARIANTS = [
|
||||||
@@ -77,3 +81,11 @@ test('the commands and skills the adapter reuses are present', () => {
|
|||||||
assert.ok(fs.existsSync(path.join(root, rel)), `reused file missing: ${rel}`);
|
assert.ok(fs.existsSync(path.join(root, rel)), `reused file missing: ${rel}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Gemini cannot auto-discover Claude/Codex hook events', () => {
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(root, GEMINI_AUTO_HOOKS)),
|
||||||
|
false,
|
||||||
|
`${GEMINI_AUTO_HOOKS} is auto-loaded by Gemini CLI; keep Claude/Codex hooks on manifest paths`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const root = path.join(__dirname, '..');
|
const root = path.join(__dirname, '..');
|
||||||
const HOOKS_JSON = 'hooks/hooks.json';
|
const HOOKS_JSON = 'hooks/claude-codex-hooks.json';
|
||||||
|
const HOST_PLUGIN_MANIFESTS = [
|
||||||
|
'.claude-plugin/plugin.json',
|
||||||
|
'.codex-plugin/plugin.json',
|
||||||
|
];
|
||||||
// cmd.exe variable syntax (%FOO%); PowerShell leaves it literal, breaking the path.
|
// cmd.exe variable syntax (%FOO%); PowerShell leaves it literal, breaking the path.
|
||||||
const CMD_VAR_SYNTAX = /%[A-Za-z_][A-Za-z0-9_]*%/;
|
const CMD_VAR_SYNTAX = /%[A-Za-z_][A-Za-z0-9_]*%/;
|
||||||
// Pull the hooks/<script> a command launches, so we can check it exists.
|
// Pull the hooks/<script> a command launches, so we can check it exists.
|
||||||
@@ -46,3 +50,10 @@ test('every hook command points at a script that ships in hooks/', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Claude and Codex manifests point at the shared host-specific hook config', () => {
|
||||||
|
for (const rel of HOST_PLUGIN_MANIFESTS) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
||||||
|
assert.equal(manifest.hooks, `./${HOOKS_JSON}`, `${rel} must not rely on root hooks auto-discovery`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,6 +64,23 @@ assert.equal(fs.existsSync(codexState), false);
|
|||||||
output = JSON.parse(result.stdout);
|
output = JSON.parse(result.stdout);
|
||||||
assert.equal(output.systemMessage, 'PONYTAIL:OFF');
|
assert.equal(output.systemMessage, 'PONYTAIL:OFF');
|
||||||
|
|
||||||
|
// A request that merely mentions "normal mode" must not deactivate ponytail.
|
||||||
|
result = run('ponytail-mode-tracker.js', codexEnv, JSON.stringify({ prompt: '@ponytail lite' }));
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite');
|
||||||
|
|
||||||
|
result = run(
|
||||||
|
'ponytail-mode-tracker.js',
|
||||||
|
codexEnv,
|
||||||
|
JSON.stringify({ prompt: 'add a normal mode toggle next to dark mode' }),
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(codexState, 'utf8'),
|
||||||
|
'lite',
|
||||||
|
'incidental "normal mode" in a request must not turn ponytail off',
|
||||||
|
);
|
||||||
|
|
||||||
const claudeEnv = {
|
const claudeEnv = {
|
||||||
HOME: home,
|
HOME: home,
|
||||||
USERPROFILE: home,
|
USERPROFILE: home,
|
||||||
|
|||||||
Reference in New Issue
Block a user