Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0b0a1b52 |
@@ -1,24 +0,0 @@
|
|||||||
# 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,6 +5,5 @@
|
|||||||
"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,7 +11,6 @@
|
|||||||
"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,7 +31,6 @@ End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. S
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
Complexity only, correctness bugs, security holes, and performance go to a
|
||||||
and performance are explicitly out of scope — route them to a normal review
|
normal review pass. Lists findings, applies nothing. One-shot.
|
||||||
pass. Lists findings, applies nothing. One-shot.
|
|
||||||
"stop ponytail-audit" or "normal mode" to revert.
|
"stop ponytail-audit" or "normal mode" to revert.
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
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,7 +26,6 @@ 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,9 +44,8 @@ If there is nothing to cut, say `Lean already. Ship.` and stop.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
Complexity only, correctness bugs, security holes, and performance go to a
|
||||||
and performance are explicitly out of scope — route them to a normal review
|
normal review pass, not this one. A single smoke test or `assert`-based
|
||||||
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.
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
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,9 +11,6 @@ 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);
|
||||||
@@ -45,18 +42,7 @@ 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
@@ -1,249 +0,0 @@
|
|||||||
<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-14%20agents-111111?style=flat-square" alt="Works with 14 agents">
|
<img src="https://img.shields.io/badge/works%20with-13%20agents-111111?style=flat-square" alt="Works with 13 agents">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
|
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -23,10 +23,6 @@
|
|||||||
<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.
|
||||||
@@ -46,8 +42,6 @@ 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.
|
||||||
@@ -107,8 +101,6 @@ 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
|
||||||
@@ -160,8 +152,6 @@ 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
|
||||||
@@ -169,7 +159,6 @@ 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
|
||||||
|
|
||||||
@@ -181,17 +170,13 @@ 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, 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/`.
|
Installs ponytail as an OpenClaw skill from ClawHub; the review, audit, debt, and help skills install the same way (`clawhub install ponytail-review`, and so on). OpenClaw applies it on coding tasks and also exposes it as a `/ponytail` command. Without ClawHub, copy [`.openclaw/skills/ponytail`](.openclaw/skills/) into `~/.openclaw/skills/`.
|
||||||
|
|
||||||
That was it. He'd be proud. He won't say it.
|
That was it. He'd be proud. He won't say it.
|
||||||
|
|
||||||
@@ -199,7 +184,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, 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/)).
|
Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/)).
|
||||||
|
|
||||||
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
|
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
|
||||||
|
|
||||||
@@ -217,7 +202,6 @@ 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**: 7 surgical "implement this
|
chooses how much to build; LOC is the `git diff`. **Safety tier**: 6 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,7 +55,6 @@ 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.
|
||||||
@@ -88,27 +87,6 @@ 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
|
||||||
@@ -124,8 +102,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 (7 surgical tasks):
|
# safety tier (6 surgical tasks):
|
||||||
python run.py --task safe-path,critic-email,rate-limit,sql-user,auth-token,csv-sum,cache \
|
python run.py --task safe-path,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
|
||||||
```
|
```
|
||||||
@@ -139,13 +117,11 @@ 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 **or
|
- It **can** show whether a skill keeps code minimal *without* dropping safety, on real
|
||||||
completeness**, on real multi-file edits, across model sizes, with variance. Less code that
|
multi-file edits, across model sizes, with variance.
|
||||||
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 (`judge.py`), and the "did it actually build the feature" question by a second
|
LLM judge in a later pass.
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/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, system=RUBRIC):
|
def judge_call(task_prompt, files, key, retries=3):
|
||||||
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": system, "messages": [{"role": "user", "content": user}]}).encode()
|
"system": RUBRIC, "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, os, re, shutil, statistics, subprocess, sys, tempfile
|
import argparse, concurrent.futures, datetime, json, re, shutil, statistics, subprocess, sys, tempfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -43,22 +43,11 @@ 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). The smoke test verifies activation by output style.
|
# cache dir (--plugin-dir). Local absolute paths; the smoke test verifies activation by output style.
|
||||||
PLUGIN_ARMS = ("ponytail", "caveman") # arms activated via --plugin-dir (vs raw --append prompts)
|
PLUGIN_DIRS = {
|
||||||
PLUGIN_CACHE = Path.home() / ".claude" / "plugins" / "cache"
|
"ponytail": r"C:\Users\Dietr\.claude\plugins\cache\ponytail\ponytail\4.2.0",
|
||||||
|
"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
|
||||||
|
|
||||||
@@ -157,30 +146,9 @@ 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
|
||||||
@@ -249,8 +217,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_ARMS:
|
if arm in PLUGIN_DIRS:
|
||||||
cmd += ["--plugin-dir", _plugin_dir(arm)] # real activation of exactly one plugin
|
cmd += ["--plugin-dir", PLUGIN_DIRS[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,52 +357,6 @@ 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
|
||||||
@@ -448,13 +402,6 @@ 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 "
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
// Baseline arm: no skill, with a one-line system prompt so the model doesn't ramble.
|
// Baseline arm: no skill, just the task.
|
||||||
const system = 'Provide just one example for any given task, and no commentary or usage examples.';
|
module.exports = ({ vars }) => [{ role: 'user', content: vars.task }];
|
||||||
module.exports = ({ vars }) => [
|
|
||||||
{ role: 'system', content: system },
|
|
||||||
{ role: 'user', content: vars.task },
|
|
||||||
];
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
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-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."
|
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."
|
||||||
|
|||||||
@@ -8,18 +8,17 @@ to load in a given agent.
|
|||||||
|
|
||||||
| Host | Files | Notes |
|
| Host | Files | Notes |
|
||||||
|------|-------|-------|
|
|------|-------|-------|
|
||||||
| 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. |
|
| Claude Code | `.claude-plugin/`, `commands/`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. |
|
||||||
| 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. |
|
| Codex | `.codex-plugin/plugin.json`, `hooks/hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
|
||||||
| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. |
|
| 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. The Claude/Codex hook map is not placed at Gemini's auto-discovered `hooks/hooks.json` path. |
|
| 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. |
|
||||||
| 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. |
|
||||||
@@ -36,6 +35,5 @@ 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
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# 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_CONFIG_DIR/.ponytail-active (defaults to ~/.claude; statusline reads this)
|
// 1. Writes flag file at ~/.claude/.ponytail-active (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,9 +43,7 @@ let output = getPonytailInstructions(mode);
|
|||||||
if (!isCodex) try {
|
if (!isCodex) try {
|
||||||
let hasStatusline = false;
|
let hasStatusline = false;
|
||||||
if (fs.existsSync(settingsPath)) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
// Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse)
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||||
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
|
|
||||||
const settings = JSON.parse(raw);
|
|
||||||
if (settings.statusLine) {
|
if (settings.statusLine) {
|
||||||
hasStatusline = true;
|
hasStatusline = true;
|
||||||
}
|
}
|
||||||
@@ -71,8 +69,4 @@ if (!isCodex) try {
|
|||||||
// Silent fail — don't block session start over statusline detection
|
// Silent fail — don't block session start over statusline detection
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
writeHookOutput('SessionStart', mode, output);
|
writeHookOutput('SessionStart', mode, output);
|
||||||
} catch (e) {
|
|
||||||
// Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -33,15 +33,6 @@ 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');
|
||||||
@@ -107,6 +98,5 @@ 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, isDeactivationCommand } = require('./ponytail-config');
|
const { getDefaultMode } = 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 (isDeactivationCommand(prompt)) {
|
if (/\b(stop ponytail|normal mode)\b/i.test(prompt)) {
|
||||||
clearMode();
|
clearMode();
|
||||||
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
|
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
|
$Flag = Join-Path $HOME ".claude/.ponytail-active"
|
||||||
$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,6 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# CLAUDE_CONFIG_DIR overrides ~/.claude, matching where the hooks write the flag (issue #34)
|
flag="$HOME/.claude/.ponytail-active"
|
||||||
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,7 +7,6 @@ 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");
|
||||||
@@ -120,11 +119,6 @@ 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),
|
||||||
@@ -139,7 +133,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" && isDeactivationCommand(text)) {
|
if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(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-gain", "ponytail-help", "ponytail-review"]);
|
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-audit", "ponytail-debt", "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,14 +100,12 @@ 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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -123,15 +121,3 @@ 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 = new URL("../../skills/ponytail/SKILL.md", import.meta.url);
|
const skillPath = join(import.meta.dirname, "..", "..", "skills", "ponytail", "SKILL.md");
|
||||||
const body = readFileSync(skillPath, "utf8");
|
const body = readFileSync(skillPath, "utf8");
|
||||||
|
|
||||||
const filtered = filterSkillBodyForMode(body, "full");
|
const filtered = filterSkillBodyForMode(body, "full");
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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());
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// 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));
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ponytail-mcp",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.",
|
|
||||||
"type": "module",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": { "ponytail-mcp": "./index.js" },
|
|
||||||
"scripts": { "test": "node --test ./test/*.test.js" },
|
|
||||||
"dependencies": {
|
|
||||||
"@modelcontextprotocol/sdk": "^1.19.0",
|
|
||||||
"zod": "^3.23.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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,7 +21,6 @@ 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,7 +20,6 @@ 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,7 +35,6 @@ End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. S
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
Complexity only, correctness bugs, security holes, and performance go to a
|
||||||
and performance are explicitly out of scope — route them to a normal review
|
normal review pass. Lists findings, applies nothing. One-shot.
|
||||||
pass. Lists findings, applies nothing. One-shot.
|
|
||||||
"stop ponytail-audit" or "normal mode" to revert.
|
"stop ponytail-audit" or "normal mode" to revert.
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
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,7 +27,6 @@ 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,9 +49,8 @@ If there is nothing to cut, say `Lean already. Ship.` and stop.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Scope: over-engineering and complexity only. Correctness bugs, security holes,
|
Complexity only, correctness bugs, security holes, and performance go to a
|
||||||
and performance are explicitly out of scope — route them to a normal review
|
normal review pass, not this one. A single smoke test or `assert`-based
|
||||||
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,7 +10,6 @@ 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
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,16 +39,6 @@ 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,10 +25,6 @@ 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 = [
|
||||||
@@ -81,11 +77,3 @@ 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,11 +11,7 @@ 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/claude-codex-hooks.json';
|
const HOOKS_JSON = 'hooks/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.
|
||||||
@@ -50,10 +46,3 @@ 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,23 +64,6 @@ 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