Compare commits

...

97 Commits

Author SHA1 Message Date
Dietrich Gebert 356918eba9 docs: rename the Retriever logo files so GitHub serves the new version 2026-09-07 18:27:00 +02:00
Dietrich Gebert 846fd86ff1 docs: Retriever logo with the name below the icon 2026-09-07 18:26:12 +02:00
Dietrich Gebert 57f759d7ff docs: Retriever as one logo image, icon and name together, dark and light 2026-09-07 18:23:58 +02:00
Dietrich Gebert a3624bdc64 docs: Retriever icon and name on one line 2026-09-07 18:22:34 +02:00
Dietrich Gebert 7f337d7955 docs: crop the transparent margin off the Retriever icon 2026-09-07 18:21:27 +02:00
Dietrich Gebert c3b62633c7 docs: icon and name in one block, rule after the section 2026-09-07 18:19:54 +02:00
Dietrich Gebert fb720d30b0 docs: Already built with Ponytail as a real section, left aligned 2026-09-07 18:18:54 +02:00
Dietrich Gebert 210d917c86 docs: drop the tagline under the Retriever icon 2026-09-07 18:17:55 +02:00
DietrichGebert c5cd7abe6d docs: Built with Ponytail, Retriever (#830)
* docs: Built with Ponytail, Retriever

* docs: Built with Ponytail, icon and link only

* docs: Already built with Ponytail, under the waitlist banner

---------

Co-authored-by: Dietrich Gebert <dgebert@Dietrichs-MacBook-Pro.local>
2026-09-07 18:17:12 +02:00
DietrichGebert 974d940a1c docs: put daily, weekly and monthly Trendshift badges in one row (#803)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:35:29 +02:00
DietrichGebert 7e81f61e4b docs: add Trendshift monthly ranking badge (#802)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:32:12 +02:00
DietrichGebert 01b1cd885c docs: add Trendshift badge to READMEs (#801)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:25:19 +02:00
Peter 2ed6c52c9d feat: add Grok Build native skills adapter (revive #561) (#661)
* feat: add Grok Build adapter (revive #561 on current main)

Thin Grok packaging under .grok-plugin/ with root plugin.json path
overrides (hooks + MCP). SessionStart/UserPromptSubmit/SubagentStart
reuse shared hooks/ponytail-*.js; mode state under GROK_PLUGIN_DATA.

Rebases the approach from #561 onto current main: keep Qoder detection
and output paths, add isGrok, export getGrokPluginDataDir, drop bash-only
exec from Grok hooks, and document install/enable/uninstall on the
front-page README (en/es/ko) plus agent-portability.

Direct install works today:
  grok plugin install DietrichGebert/ponytail --trust

Marketplace root source ("./") matches Claude; Grok's scanner still
rejects it (see xai-org/plugin-marketplace#123 class of bugs).

Co-authored-by: Vinícius Souza <souza.vinicius@bb.com.br>

* fix(grok): drop MCP, harden host detection and tests

Review feedback on #661:
- Remove MCP wiring (git install never installs ponytail-mcp deps; no
  other host ships MCP; hooks+skills cover always-on)
- Drop static plugin-index.json (optional catalog fluff)
- Clear GROK_PLUGIN_* in hooks.test.js so host suites cannot leak
- Exclusive isGrok after Copilot/Codex; state falls back to ROOT not ~/.claude
- Tighten Qoder regression assert; structural checks for plugin.json/hooks
- List Grok Build among skill-capable hosts in README

* refactor(grok): DRY — reuse Claude/Codex hooks map

Second review pass for #661:

- Delete .grok-plugin/hooks.json (near-copy of claude-codex-hooks.json).
  Root plugin.json points at the shared map; Grok sets CLAUDE_PLUGIN_ROOT.
- Drop getGrokPluginDataDir; inline GROK_PLUGIN_DATA || ROOT like other hosts.
- Grok uses Claude-compatible writeHookOutput (raw SessionStart, JSON
  SubagentStart) instead of a separate raw-only branch.
- Slim .grok-plugin/marketplace.json to match .claude-plugin.
- Tests: shared-map assert, SubagentStart JSON under Grok, Qoder isolation.

* fix(grok): use native skill activation

* chore: drop unrelated Qoder formatting

---------

Co-authored-by: Vinícius Souza <souza.vinicius@bb.com.br>
2026-08-08 00:44:01 +03:00
krish rathi a2712bc830 fix: detect VS Code Copilot via CLAUDE_PLUGIN_ROOT fallback (#528) (#579)
VS Code Copilot never sets COPILOT_PLUGIN_DATA, only CLAUDE_PLUGIN_ROOT
(pointed at an .vscode/agent-plugins/... install path), so isCopilot was
false and ponytail assumed native Claude Code — emitting the statusline
setup nudge, which VS Code Copilot doesn't read. Also guard stateDir:
it was built from the same unset COPILOT_PLUGIN_DATA whenever isCopilot
resolved via this new fallback, so fall back to getClaudeDir() there too.
2026-08-08 00:37:09 +03:00
Prayag Raj cc37a5d581 fix: drop commandWindows from hooks.json for Claude.ai marketplace validation (#593) (#601)
commandWindows is not part of the supported hooks schema for the Claude.ai
plugin marketplace validator, which rejects it as an unknown field. The
shared `command` field already runs cross-platform: Claude Code expands
${CLAUDE_PLUGIN_ROOT} before the shell sees it, VS Code Copilot ignores
commandWindows and runs `command` through PowerShell on Windows, and `node`
is natively available in both bash and PowerShell.

Update the regression test to assert commandWindows is absent and remove
the now-unused CMD_VAR_SYNTAX guard. Fix comment placeholder syntax
({{CLAUDE_PLUGIN_ROOT}} → ${CLAUDE_PLUGIN_ROOT}) and narrow test name.
2026-08-08 00:34:41 +03:00
DietrichGebert 0a4dd63ad4 chore: release v4.9.0 (#703)
Co-authored-by: Dietrich Gebert <dgebert@Dietrichs-MacBook-Pro.local>
2026-08-08 00:13:25 +03:00
DietrichGebert 16f29800fd docs: FAQ entry on using caveman with ponytail (#599)
Most-asked question, and the answer was only ever in the skill file
(skills/ponytail/SKILL.md: "pair with Caveman for terse prose"), never
in the README. Added to both READMEs as the first FAQ entry.

Co-authored-by: Dietrich Gebert <dgebert@Dietrichs-MacBook-Pro.local>
2026-07-15 23:32:15 +02:00
gglucass 14a0d79548 Emit statusline setup nudge at most once per user (#483)
Writes a flag file the first time the STATUSLINE SETUP NEEDED nudge fires, and skips it on later sessions so it stops nagging every start. Includes a regression test that a second session stays silent.
2026-07-10 04:44:32 +02:00
Cody Jung f12f210eb5 fix(benchmarks): kill timed-out agent cells cross-platform (#225)
run_cell's timeout handler called `taskkill /F /T`, a Windows-only command, with
no POSIX branch. On macOS/Linux a timed-out cell raised FileNotFoundError instead
of being killed, leaking the agent's process tree and logging the cell as an error
rather than a clean timeout-kill.

Branch the kill on platform: taskkill on Windows, os.killpg on POSIX. The cell now
starts in its own session (Popen start_new_session) so its process group is
killable. A _selftest_kill check (run.py --selftest) spawns a sleeper and asserts
the tree-kill terminates it on whatever platform CI runs.
2026-07-10 03:24:23 +02:00
Dhruv Garg 0cdd11fe0c fix: stop filterSkillBodyForMode from swallowing rule bullets that start with a mode word (#571)
filterSkillBodyForMode is the shared filter every host adapter (Claude
hooks, Pi, OpenCode, the MCP server, Hermes) uses to trim the ponytail
skill body down to the active intensity. Its worked-example detection
matched any bullet shaped "- label: text" and treated it as a
mode-specific example whenever the label happened to normalize to a
mode name (lite/full/ultra/off) -- silently dropping it in every other
mode.

The current SKILL.md doesn't trip this today (its only such bullets are
the three real worked examples), but it's a landmine: an ordinary rule
bullet added later that happens to start with "Full:" or "Lite:" would
vanish from every other mode's instructions with no warning, in the one
code path every adapter shares. Confirmed with a real bullet:

  filterSkillBodyForMode('- Full: real rule text.', 'ultra')

silently drops the line before this fix.

All three real worked examples are quoted (`- lite: "..."`); ordinary
rule bullets aren't. Required the quote so only genuine examples are
mode-filtered, and added a regression test plus fixed an existing test
whose synthetic fixture used unquoted examples that don't match the
real file's format.
2026-07-10 03:04:06 +02:00
DietrichGebert b6c04480c0 fix: narrow the ponytail: marker to real corner-cuts, keep the prefix (#120) (#577)
The ponytail: comment marker was being over-applied to trivial code (#120).
Narrow the rule so only deliberate simplifications that cut a real corner with a
known ceiling get a comment, and keep the `ponytail:` prefix that /ponytail-debt
greps for. Applied across all rule copies, SKILL.md, AGENTS.md, the fallback
instructions, both command files, and the Qoder/OpenCode copies that #491
missed; .openclaw regenerated. helpers.test.js now pins the prefix so it cannot
silently drop again.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 02:52:02 +02:00
DietrichGebert 65db9025a7 fix: reject review as a default mode in pi-extension and config (#576)
review is a session-only mode set by /ponytail-review, never a persistent
default. The Claude/Codex mode-tracker already enforces this (#377), but two
paths did not:

- pi-extension /ponytail default review was accepted, persisting review as the
  default, so the next session started in review with a blank status-bar icon.
- getDefaultMode accepted review from PONYTAIL_DEFAULT_MODE or a stale config.

Route default validation through RUNTIME_MODES (off/lite/full/ultra) in
getDefaultMode, writeDefaultMode, and the pi-extension default command. review
stays valid as a session mode. Adds regression tests for all three paths.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:48:24 +02:00
saime428 3465b1a3ca Fix Codex hook output schema (#573) (#574)
Restore hookSpecificOutput so strict Codex schemas accept SessionStart, UserPromptSubmit, and SubagentStart context.

Co-authored-by: saime <2286263079@qq.com>
2026-07-10 01:16:30 +02:00
Sandeep pandey 2ba0262111 fix: drop bash-only exec from hooks so they run under PowerShell (#527, #569) (#572)
The three shared `command` fields in claude-codex-hooks.json used
`exec node "..."`. `exec` is a bash/zsh builtin with no PowerShell
equivalent. Some hosts run the `command` field through PowerShell on
Windows regardless of the PowerShell-native `commandWindows` field:
VS Code Copilot always does (it never reads `commandWindows`), and
native Claude Code launched from Git Bash was reported doing the same
(#569). PowerShell then fails on the first token with
CommandNotFoundException, so every SessionStart / SubagentStart /
UserPromptSubmit hook errors out on Windows.

Plain `node "..."` runs natively in both bash and PowerShell. Dropping
`exec` only gives up the POSIX optimization of replacing the wrapper
shell with node; the wrapper-process pileup that #461 added `exec` to
avoid is already handled by each hook's stdin self-exit guard
(#443/#477), so there is no regression on Codex/zsh.

Updated the hooks-windows regression test to assert the shell-agnostic
form instead of requiring `exec`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 01:06:07 +02:00
nanaubusiness 523e9dc051 fix: read version from package.json instead of hardcoding 0.1.0 (#354)
Closes #345

Co-authored-by: Admin <admin@Admins-MacBook-Pro.local>
2026-07-09 18:06:25 +02:00
Aly Dhedhi 33c00d3d7a fix: Codex CLI SessionStart additionalContext at top level (#505) (#508)
* fix: Codex CLI SessionStart additionalContext at top level (#505)

Codex CLI expects additionalContext at the top level of the hook output,
not nested under hookSpecificOutput. Flattened the Codex branch of
writeHookOutput so the injected instructions actually reach the agent.

Fixes #505

* Merge main; move #99 Codex @ponytail report assertion to top-level additionalContext (#505)

---------

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 18:03:43 +02:00
guyoron1 055a1453d3 fix: merge system entries in OpenCode plugin for Qwen compat (#296) (#536)
Qwen models reject multiple system messages — push() added a second
entry.  Append to the last existing entry instead; fall back to push
when the array is empty.

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 17:53:06 +02:00
guyoron1 fee48ca59a fix: guard against non-string input.arguments in OpenCode plugin (#557)
Wrap input.arguments with String() before calling .trim(), matching
the defensive pattern already used in the pi-extension (index.js:90).
Prevents TypeError if the host passes a non-string truthy value.

Fixes #553.

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 17:52:22 +02:00
guyoron1 8555a0a580 fix: catch fs errors when writing default mode in pi-extension (#559)
writeDefaultMode() calls mkdirSync/writeFileSync which can throw on
permission errors or disk-full. Wrap in try-catch so the user gets
a notification instead of an unhandled promise rejection.

Fixes #555.
2026-07-09 17:43:37 +02:00
Anders Brams 106c0a47a6 Revise Ponytail plugin installation steps (#545)
Updated installation instructions for Ponytail plugin.

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 17:39:47 +02:00
Hrqz 6165b7011a fix(pi-extension): guard theme access with try-catch for pi-web compatibility (#336)
pi-coding-agent 0.80.x uses a Proxy for the theme object that throws
'Theme not initialized' when accessed before initTheme(). In pi-web,
the session_start event fires before the theme is ready, causing
the optional chaining guard to fail (Proxy is truthy, ?. can't
catch Proxy get-trap throws).

Wrap theme access in try-catch so the extension degrades gracefully.

Co-authored-by: huantian666 <huantian666@gmail.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 17:27:28 +02:00
krish rathi 1715abccac feat(hooks): opt-in agent-type scoping for SubagentStart injection (#… (#522)
* feat(hooks): opt-in agent-type scoping for SubagentStart injection (#506)

The SubagentStart hook injected the ~1,300-token persona into every
subagent, including read-only search agents where it is dead weight.
Consumers could not narrow a plugin-provided hook from their own
settings, so the scoping has to live here.

Add an opt-in allowlist: PONYTAIL_SUBAGENT_AGENTS (comma-separated env
var) or a subagentAgents array in config.json, matching getDefaultMode
precedence. The hook now reads agent_type from stdin and skips
injection only on a positive mismatch; unconfigured or missing
agent_type keeps the inject-everywhere behavior from issue #252. Stdin
reading follows the never-hang contract from issue #443.

Closes #506

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review: regex matcher, stdin-free default path, exit on skip

Rework the SubagentStart scoping per review on #522:

- Replace the comma-separated PONYTAIL_SUBAGENT_AGENTS allowlist with a
  regex PONYTAIL_SUBAGENT_MATCHER, matching the reviewed API on #507 and
  the PONYTAIL_DEFAULT_MODE naming convention.
- Default path (no matcher) injects synchronously and never reads stdin,
  fixing the #443-class Windows regression where the persona was delayed
  until a 1s timeout.
- On a definite agent_type mismatch the hook now process.exit(0)s
  immediately instead of lingering until the fallback timer.
- Drop the config-file (subagentAgents) source, so no config read on the
  subagent hot path; env-only keeps it lazy (YAGNI).
- Revert the unrelated tests/hooks-windows.test.js change; the scoped
  path mirrors the mode-tracker never-hang contract already tested there.

Fail open on unparseable input, missing agent_type, invalid regex, or
timeout so scoping never silently drops the persona (#252).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 17:23:07 +02:00
shixiaoding 83493b97bc feat: add Qoder hooks support (UserPromptSubmit + PreToolUse) (#529)
* feat: add Qoder hooks support (UserPromptSubmit + PreToolUse)

- Add isQoder detection via QODER_SESSION_ID in ponytail-runtime.js
- Add UserPromptSubmit double-duty: auto-activate + ruleset injection
- Add PreToolUse hook with task|Task matcher for subagent injection
- Create hooks/qoder-hooks.json reference template
- Update .qoder-plugin/plugin.json with hooks field
- Add .qoder/rules/ponytail.md for per-project rules
- Add comprehensive tests for Qoder runtime behavior
- Update README.md and docs/agent-portability.md

* check-versions: enforce .qoder-plugin/plugin.json version too

---------

Co-authored-by: shiding <shiding@ruigushop.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 17:13:59 +02:00
Hamza Ali Shahjahan 8f32ae0f6e docs: add JetBrains Junie, Amp, and Jules support (native AGENTS.md readers) (#326)
* docs: add JetBrains Junie support (native AGENTS.md reader)

Junie reads AGENTS.md from the project root natively (per JetBrains' official docs), so ponytail works zero-config in any JetBrains IDE — like CodeWhale (#124). Adds a README note + agent-portability row, and bumps the agent badge 16 -> 17. Docs-only.

* docs: also add Amp and Jules support (native AGENTS.md readers)

Amp (Sourcegraph) reads AGENTS.md as its primary guidance file (cwd + parents up to $HOME + global config; per ampcode.com/manual). Jules (Google) auto-reads AGENTS.md from the repo root (per jules.google/docs). Both work zero-config via the AGENTS.md this repo ships. Badge 17->19. Roo Code evaluated and excluded (archived 2026-05-15).

* Merge main; correct Junie AGENTS.md note (Guidelines Path, not automatic)

---------

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 12:48:17 +02:00
Philipp Metzler d5f4ffdd65 pi-extension: make startup notification opt-out via quietStartup (#308)
The status bar already shows the active ponytail mode at all times, but the
startup toast is also a deliberate trust-heartbeat for a behavior-modifying
extension (it confirms ponytail loaded, in which mode, and survives session
resume where the persisted mode may differ from the default). Default
behavior is preserved.

Users who prefer a clean startup can now silence it via:

  - config:  ~/.config/ponytail/config.json -> { "quietStartup": true }
  - env var: PONYTAIL_QUIET_STARTUP=1

Resolution order mirrors the existing defaultMode setting: env var > config
file > default (false). Added readQuietStartup export and a test covering
all three layers.

Co-authored-by: blissini <blissini@users.noreply.github.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 12:29:12 +02:00
Yash Yadav 9888cdf066 docs: update Claude Code Desktop install steps to match the current UI (#342) (#525)
The desktop-app note pointed at a Customize > + > "Create plugin and
add marketplace" > "Add from repository" flow (from #98) that no
longer matches the app. The unified Claude Code Desktop app's Code
tab runs real Claude Code sessions, so the same /plugin marketplace
add and /plugin install commands typed above work directly in its
prompt box; the + button also opens a Plugins browser, and
marketplaces are managed under Customize in the sidebar (per
Anthropic's current desktop app docs).

Closes #342
2026-07-09 11:52:05 +02:00
Ben Younes efa35c34f2 fix: preserve combined statusline on uninstall (#550) 2026-07-09 11:48:37 +02:00
D3VL0PR 511ddd4d62 fix(opencode): stop exporting parseCommandFile from plugin module (#301)
OpenCode's legacy plugin loader iterates every exported function in a
plugin module and tries to invoke each one with the plugin context object.
parseCommandFile was exported alongside the default plugin function, so
the loader called it with an object instead of a file path and crashed
with 'path must be a string or a file descriptor' before any hook ran,
which aborted the whole plugin load.

Move the frontmatter parser to its own .cjs sibling; the plugin module
now exposes exactly one top-level export (the default plugin function).
The plugin requires the parser via createRequire, and the existing
parseCommandFile tests now require the sibling directly.

Co-authored-by: ayushere <ayushere@users.noreply.github.com>
2026-07-09 11:44:31 +02:00
Jay shah 8e69b4a55f feat: report active level on bare /ponytail instead of resetting to default (#99)
* fix: report active level on bare /ponytail instead of resetting to default

* remove . ponytail-active

---------

Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-07-09 04:15:43 +02:00
JACK 6d0c111035 Fix ponytail test coverage and metadata (#503)
Co-authored-by: Jack <jack@JackdeMacBook-Pro.local>
2026-07-09 03:35:39 +02:00
Hannes 72274181e9 fix(pi-extension): list ponytail modes (#237) 2026-07-09 03:29:35 +02:00
Hamza Ali Shahjahan fb72987fb5 fix(benchmarks): make agentic fixture path portable via PONYTAIL_TMPL (#206)
tasks.py hardcoded _TMPL to a personal absolute Windows path (D:\dev\...), which the agentic benchmark's Reproduce flow depends on. Resolve it via a PONYTAIL_TMPL env override with a relative fallback, mirroring the existing PONYTAIL_PLUGIN_DIR convention in run.py; run.py's loader resolves the relative name under fixtures/. selftest skips the open tasks that consume _TMPL, so instrument validation is unaffected (verified: all instruments valid).
2026-07-09 03:13:52 +02:00
Sangeeth Thilakarathna a3e0169c3d fix: ignore invalid opencode mode arguments (#302)
Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-09 03:12:14 +02:00
MrKC ca4dad252d test: run ponytail-mcp test suite under root npm test (#235) 2026-07-09 03:08:41 +02:00
Rafael Pinheiro bbace73bed check-rule-copies: pin the reuse rung as an invariant (#281)
The reuse rung added in #253 ("Already in this codebase? Reuse it, don't
re-write it.") is load-bearing now, but it was not in the INVARIANTS list,
so a future reword could silently drop it from SKILL.md or AGENTS.md without
tripping the check. Pin it with the substring "in this codebase", present in
both files' rung 2, the same way the safety carve-outs are pinned.

Co-authored-by: Rafaelpta <15382305+Rafaelpta@users.noreply.github.com>
2026-07-09 03:04:19 +02:00
Lakshya Sharma 187acc2add ux: make the ultra statusline badge stand out (#192)
Every mode rendered in the same green, so ultra (the high-intensity mode
that deletes and challenges requirements) looked identical to lite/full at
a glance. Give the ultra badge an amber color so it's obvious you're in it.
The level is still in the badge text, so color stays a redundant cue, not
the only one.
2026-07-09 03:01:14 +02:00
Hamza Ali Shahjahan eb3b6f0bbd docs: align benchmark prerequisites with promptfoo's Node requirement (#205)
The Prerequisites section listed Node.js 18+, but the Reproduce section (and promptfoo's engine constraint) requires >= 22.22.0, so a reader on Node 18 hits promptfoo's engine error. The local Ollama path uses no Node, so 18+ had no basis. One-line docs change aligning the prerequisite with the existing requirement above it.
2026-07-09 02:57:37 +02:00
Lakshya Sharma 01a29e480a docs: add Zed to the agent portability table (#190)
The README lists Zed as a supported instruction-tier agent and points
readers to docs/agent-portability.md for the file mapping, but the table
had no Zed row. Zed auto-includes AGENTS.md from the worktree root as a
default rule file, so add the row to match the README.
2026-07-09 02:56:12 +02:00
DietrichGebert a439cd2930 docs: reword Copilot Chat note without em dash (#526) (#562) 2026-07-09 02:54:11 +02:00
Yash Yadav a195670068 docs: clarify that GitHub Copilot Chat (VS Code/JetBrains) is supported (#497) (#526)
"GitHub Copilot (editor)" was buried in a comma-separated adapter
list with no indication of which product it meant, so readers asking
"does this support VS Code Copilot?" had no way to tell from the
README. Spell out VS Code/JetBrains/Visual Studio explicitly and
disambiguate from the separately documented Copilot CLI.

Closes #497
2026-07-09 02:39:16 +02:00
Lorenzo Sanz 6c08d36447 Update Spanish README with improved wording (#560) 2026-07-09 01:25:23 +02:00
DietrichGebert 1b2760d384 feat: hide the pi status bar indicator while keeping ponytail active (#324) (#544)
Add getHideStatus() to the shared config resolver: PONYTAIL_HIDE_STATUS (any
truthy value; 0/false/empty mean don't hide) or config.hideStatus === true, with
BOM-stripped config reads. The pi extension caches it at init and re-reads on
session_start, and syncStatus() returns early when set, so the indicator is
suppressed while the ruleset still injects every turn.

Reconciles #511 and #328: takes #328's caching and ruleset-stays-active test,
#511's BOM-safe config read, a stricter env check (0/false do not hide), and
drops #328's writeDefaultMode change which already landed in #514.

Closes #324

Co-authored-by: dhedhialy <91044156+dhedhialy@users.noreply.github.com>
Co-authored-by: sanmaxdev <46221775+sanmaxdev@users.noreply.github.com>
2026-07-07 04:06:37 +02:00
DietrichGebert bed76c52fa feat: add /ponytail default <mode> to persist the default (#329) (#541)
Claude Code had no way to change the persistent default from within a session:
plain /ponytail switches are session-scoped by design ('sticks until session
end'), and only env/config could set the default. So /ponytail off never
survived a restart, which is what #329 hit.

Add /ponytail default <mode> to the mode-tracker hook, matching pi's existing
command: it writes defaultMode to config (survives restart) and does NOT touch
the current session flag, keeping plain switches transient. review is rejected
as a default (#377); only off/lite/full/ultra are accepted. Test covers persist,
transient-switch-does-not-persist, and review-rejected.

Closes #329
2026-07-07 03:09:25 +02:00
Lakshya Sharma f790cebb57 fix: statusline setup nudge honors CLAUDE_CONFIG_DIR (#338)
The activation hook detects a missing statusline in getClaudeDir()/settings.json
(CLAUDE_CONFIG_DIR-aware) but the nudge text told the user and the agent to add
the statusLine config to ~/.claude/settings.json. With CLAUDE_CONFIG_DIR set,
that is the wrong file: Claude Code never reads it and the badge never turns on.
Point the nudge at the settingsPath the hook already computed, in both the
shell-safe and manual branches.

Fixes #250
2026-07-07 02:54:46 +02:00
Aly Dhedhi 988428d510 fix: writeDefaultMode merges config instead of overwriting (#490) (#514)
writeDefaultMode was writing a fresh { defaultMode } object, silently
dropping any other fields in config.json. Now it reads the existing config,
updates only defaultMode, and writes it back preserving all other fields.

Fixes #490
2026-07-07 02:44:56 +02:00
Aly Dhedhi 3869218b13 fix: hermes test hardcoding python3 (fails on Windows) (#513)
tests/hermes-plugin.test.js spawned python3 with no fallback. On Windows
python3 is the Store app-execution-alias stub that exits non-zero even when
Python is installed. Reuse the probe/fallback pattern from
benchmarks/correctness.js: try python3, fall back to python, cache the result.

Fixes #498
2026-07-07 02:31:15 +02:00
Aly Dhedhi 146904825d fix: handle CRLF in loc.js fence regex and remove dead codeOf (#339) (#518)
loc.js regex was missing \r? before \n, so CRLF code fences were
never matched and the metric fell back to counting the whole response.
Also removed dead codeOf() from behavior.js (defined but never called).

Fixes #339
2026-07-07 02:26:10 +02:00
DietrichGebert 3efe7376ff docs(help): add /ponytail-gain to the OpenCode help card (#539)
The OpenCode /ponytail-help card listed 5 commands, omitting /ponytail-gain,
while the Claude commands/*.toml card lists all 6. An OpenCode user wouldn't
see that ponytail-gain exists. Add it so both help surfaces agree.
2026-07-07 02:00:10 +02:00
DietrichGebert 4286903a77 fix: ship scripts/uninstall.js in the npm package (#538)
The README tells users to run 'node scripts/uninstall.js' to clean up state
left outside the plugin folder, but the package files list didn't include
scripts/, so npm-installed users didn't have the file. Add scripts/uninstall.js
to files (its only dependency, hooks/, already ships). Guard it with a test
that asserts the files entry and that the file exists.

Co-authored-by: Anmolnoor <52135717+Anmolnoor@users.noreply.github.com>
2026-07-07 01:54:04 +02:00
DietrichGebert 39bad58d40 fix: guard pi before_agent_start against bad event and missing systemPrompt (#482)
The before_agent_start handler read event.systemPrompt with no guard. A
null/undefined event crashed the agent with a TypeError (#439), and a truthy
event without a systemPrompt key stringified to the literal 'undefined',
prepending garbage to every system prompt (#440). Both are fixed with one
optional-chain guard that mirrors the event?. pattern the input handler already
uses: prepend the base prompt only when present, otherwise inject the ruleset
alone. Adds a regression test covering null/undefined event, missing
systemPrompt, and the normal base-prompt path.

Closes #439
Closes #440

Co-authored-by: Aroool <90670606+Aroool@users.noreply.github.com>
2026-07-07 01:23:31 +02:00
DietrichGebert 40e50d9e03 fix: don't crash uninstall on malformed settings.json (#481)
If settings.json contained invalid JSON, JSON.parse threw a SyntaxError, which
has no .code, so the catch rethrew it and crashed the script — after the mode
flag and config file were already removed, leaving cleanup half-done. Handle
SyntaxError explicitly: warn that the statusLine entry couldn't be removed and
leave the file untouched, since invalid JSON can't be safely edited. Adds a
regression test that a malformed settings.json exits 0, warns, and is left
byte-for-byte intact.

Closes #434

Co-authored-by: isaukywhite <50426537+isaukywhite@users.noreply.github.com>
2026-07-02 01:30:30 +02:00
DietrichGebert b8f20b8e7c docs(help): list all 6 skills and fix OpenCode command count (#480)
The ponytail-help skills table listed only 4 skills, omitting ponytail-audit
and ponytail-debt (both ship as commands). It also claimed OpenCode ships only
/ponytail and /ponytail-review, when it ships all six. Add the two missing rows
and correct the note. Regenerated the .openclaw copy so it stays in sync.

Closes #437
Closes #438
2026-07-02 01:24:32 +02:00
DietrichGebert 25185a445a fix: don't destroy combined statuslines on uninstall (#479)
uninstall deleted the whole statusLine key whenever the command contained
'ponytail-statusline'. For a user-combined statusline (e.g. caveman && ponytail)
that destroyed the other plugin's statusline too. Only remove the statusLine
when ponytail owns the whole command; if it's combined with other segments,
leave it and tell the user to strip ponytail's part by hand. Never leaves a
malformed husk. Adds a regression test for the combined case.

Closes #374

Co-authored-by: nanaubusiness <195150264+nanaubusiness@users.noreply.github.com>
2026-07-02 00:39:07 +02:00
DietrichGebert 9ec4fb8b54 fix: strip UTF-8 BOM before parsing config.json (#478)
getDefaultMode() parsed the config with JSON.parse and no BOM strip, so a
config.json saved with a UTF-8 BOM (common on Windows) failed to parse and the
user's defaultMode was silently ignored. Strip the BOM first, matching the
existing /^\uFEFF/ pattern in ponytail-activate.js, check-versions.js, and
ponytail-mode-tracker.js.

Closes #375

Co-authored-by: nanaubusiness <195150264+nanaubusiness@users.noreply.github.com>
2026-07-02 00:25:27 +02:00
DietrichGebert 7e6eca6e29 fix: prevent Windows session freeze from stdin EOF deadlock (#477)
On Windows, Claude Code runs the UserPromptSubmit hook through a PowerShell
if {} wrapper that can swallow the piped prompt JSON, so stdin 'end' never
fires and ponytail-mode-tracker.js blocks forever, freezing the session.

Make the hook non-blocking: extract the handler into finish() guarded by a
done flag, and add a 1s unref()'d fallback timeout plus the existing stdin
error handler that both call finish() then exit 0. unref() means zero added
latency on the normal path where 'end' fires first. Adds a regression test
that spawns the hook with a never-closing stdin pipe and asserts self-exit.

Reconciles #453 onto main after #227 (stdin error handler) and #474 (exec
node hooks) landed.

Closes #443

Co-authored-by: Hasnan42 <154899774+Hasnan42@users.noreply.github.com>
2026-07-02 00:18:01 +02:00
Shubham Sharma b8f6fbe92a fix: handle stdin error in ponytail-mode-tracker to avoid uncaught crash (#147) (#227)
Without a stdin 'error' listener, a broken pipe or parent-process crash
causes Node to emit an unhandled error event on the stream, which becomes
an uncaught exception and crashes the hook with a non-zero exit code.

Add process.stdin.on('error', () => process.exit(0)) before the data/end
listeners. Exiting 0 on error keeps the hook contract: always exit cleanly,
never block session start.

Fixes #147
2026-07-02 00:10:24 +02:00
Sangeeth Thilakarathna 7eda70d30f fix: exec lifecycle hook commands (#474)
Co-authored-by: sanmaxdev <sanmaxdev@users.noreply.github.com>
2026-07-02 00:06:21 +02:00
DietrichGebert 16f6cbf4b8 docs(benchmarks): add Independent benchmarks section (#460)
Curated, plugin-installed third-party runs (KuldeepB19, RicardoCostaGit) with
method, headline, and date, clearly framed as independent corroboration not
official figures. Gives the recurring 'did anyone independent check this?' ask
a canonical home.

Closes #121
Closes #236
2026-06-30 02:04:48 +02:00
nanaubusiness 85cc1d9935 fix: copilot-plugin test checks all 6 command files (#393)
Previously only checked 4 of 6. ponytail-gain.toml and
ponytail-help.toml are now validated.

Closes #381

Co-authored-by: Admin <admin@Admins-MacBook-Pro.local>
2026-06-30 00:21:09 +02:00
DietrichGebert bc9ee949d5 Merge pull request #448 from DietrichGebert/release-4.8.4
release: 4.8.4
2026-06-29 07:23:36 +02:00
Emeriko c99757a14a release: bump plugin.yaml (Hermes manifest) to 4.8.4 2026-06-29 07:19:46 +02:00
Emeriko 4b7a012626 release: bump .devin-plugin to 4.8.4 2026-06-29 07:15:31 +02:00
Emeriko 801d976c69 release: 4.8.4 2026-06-29 07:12:58 +02:00
DietrichGebert adad1e451a Merge pull request #447 from DietrichGebert/fix-skill-trigger-recall
Trigger ponytail skill on any coding task, not just keywords
2026-06-29 07:08:50 +02:00
Emeriko 0e3fd0cfee Trigger ponytail skill on any coding task, not just keywords
The skill description was written keyword-first ("use when the user
says lazy / complains about boilerplate"), so a model-invoked host
only loaded it on prompts that hit those words. Plain coding tasks
(add a date picker, write a dedupe function, build a cache, fix a bug)
were skipped, exactly the over-build cases ponytail is best at.

Tested via a skill-router proxy (3 runs, 12 labeled prompts): recall
on coding tasks was 2/6 before, 6/6 after, with precision unchanged at
6/6 (no false fires on non-coding prompts). Fix is one clause naming
coding tasks explicitly plus a negative clause to keep precision.

Trigger descriptions live in two source-of-truth spots, both updated:
skills/ponytail/SKILL.md and the DESCRIPTIONS map in
scripts/build-openclaw-skills.js (.openclaw regenerated). The store
blurbs in the plugin manifests are listing metadata, not triggers, so
they are left alone.
2026-06-29 07:06:59 +02:00
DietrichGebert c4d1925ae9 Merge pull request #323 from DietrichGebert/hermes-plugin
feat: add Hermes Agent plugin (rebuilt from #78)
2026-06-26 03:28:08 +02:00
Emeriko 6eb2b7829c Merge branch 'main' into salaamdev/main
# Conflicts:
#	README.md
2026-06-26 03:24:37 +02:00
Emeriko ac75159e5e Merge branch 'main' into salaamdev/main
# Conflicts:
#	README.md
2026-06-26 03:08:24 +02:00
Lakshya Sharma 8d154e6c2a fix(benchmarks): strip block comments before counting LOC (#232)
loc.js filtered comments line by line, so /* ... */ block comments whose
continuation lines are not *-aligned had their inner lines counted as code.
A plain indented block comment scored higher than the same code written
JSDoc-style. Strip /* ... */ before the line count so both are equal, and
add loc.test.js to lock the behavior.

Fixes #231
2026-06-26 03:02:55 +02:00
systemBlue e353a1a5d3 docs: add Swift/SwiftUI section to platform-native.md (#313)
Closes #299. The "does the platform already do this?" rung is especially
rich on Apple platforms, but the doc had no Swift coverage. Adds a
Swift/SwiftUI section in the existing two-table format: SwiftUI controls
and the Swift/framework layer, with library carve-outs kept inline to
match the `ms` / `requests` style already in the doc.


Claude-Session: https://claude.ai/code/session_01MtGgxEbiXPQjqMyXWbnixn

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 02:38:34 +02:00
DietrichGebert 7147937ae8 Sync ES and Korean READMEs with the Devin CLI addition (#322)
Follow-up to #318, which updated only the English README. Bumps the agent badge 14->15, adds a Devin CLI install section before OpenClaw, and lists Devin CLI in the skill-capable host line. Also adds Swival to both host lines, which the translations were already missing.
2026-06-26 02:33:56 +02:00
Hamza Ali Shahjahan 33a4977a83 fix(benchmark): reject --ollama-url without a host (#315)
Follow-up to #274: the merged scheme validation accepts host-less URLs (http://, https://), which then fail opaquely inside urlopen(). Add a minimal netloc check after the existing scheme check, so a missing host is rejected up front with a clear argparse error. Additive; keeps the existing scheme message intact.
2026-06-26 02:28:51 +02:00
topher 7790c37b67 Add Devin CLI plugin manifest (#318)
Adds `.devin-plugin/plugin.json` so the plugin can be installed with
`devin plugins install DietrichGebert/ponytail`. The skills/ directory
is already at the repo root, which is exactly where Devin expects it.

Registers the new manifest in `scripts/check-versions.js` so it stays
in sync with every other host manifest on release.

Updates README: install/uninstall instructions, badge (14 → 15 agents),
and Devin added to the skill-capable hosts list in Commands.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-26 02:26:13 +02:00
DietrichGebert 203f5fde58 Add a Sponsors section with GreenPT as the first sponsor (#321) 2026-06-26 00:23:00 +02:00
DietrichGebert 64adbf9544 Use an announcement banner instead of the badge (#316)
Replace the flat orange badge with a clickable terminal-style banner
image: the "he's building" sticker, a bold headline, and an orange
JOIN THE WAITLIST call to action. Localized banners for EN/ES/KO.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:03:58 +02:00
DietrichGebert 7086abecc2 Make the waitlist teaser pop and translate it (#314)
The blue NOTE callout was too quiet. Replace it with a centered bold
line plus a bright orange "join the waitlist" badge (matches the site),
and add the same banner to the Spanish and Korean READMEs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:13:53 +02:00
DietrichGebert 4e5f9ccc6c Add a waitlist teaser to the README (#312)
Promotes the pre-launch waitlist at ponytail.dev/soon with a NOTE
callout just under the header.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:05:42 +02:00
DietrichGebert a945778b4a docs: add Star History chart to end of READMEs (#294)
Appends a star-history.com chart (light/dark via <picture>) to the end of
the English, Spanish, and Korean READMEs. Heading follows each file's
convention: translated in Spanish ("Historial de estrellas"), English
elsewhere.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:37:06 +02:00
DietrichGebert 6cd0c42e86 docs: add Trendshift daily and weekly badges to READMEs (#293)
Adds the daily and weekly Trendshift badges to the header badge area of
the English, Spanish, and Korean READMEs. Alt text decoded from %2F to a
plain slash for screen readers.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:30:26 +02:00
DietrichGebert 025da371cd release: 4.8.3 (#286) 2026-06-24 04:32:12 +02:00
Shane McCarron b9fa564429 feat: inject ponytail ruleset into subagents via SubagentStart hook (#254)
* feat: inject ponytail ruleset into subagents via SubagentStart hook

SessionStart additionalContext is parent-thread only, so every Task-spawned
agent ran ponytail-unaware. Add a SubagentStart hook that injects the active
ruleset into each subagent, reusing getPonytailInstructions. Native Claude
needs the hookSpecificOutput JSON form (not raw stdout), so writeHookOutput
grows a SubagentStart branch; readMode exposes the live flag.

Workflow- and team-spawned coverage is undocumented upstream; verify in a
fresh session once installed.

Closes #252

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1dMag33yz1Kf1jS24Aira

* fix: address QA round 1 — make hook tests hermetic + cover Codex SubagentStart

QA round 1 (panel + deepseek-v4-pro) found two real test issues:
- The new subagent block (and the pre-existing claudeEnv block) used a no-op
  `delete env.PLUGIN_DATA`; run() spreads process.env, so a PLUGIN_DATA /
  COPILOT_PLUGIN_DATA leaked from the shell would steer writeHookOutput into the
  codex/copilot branch and silently mis-fire the native-Claude assertions.
  Fixed at the source: neutralize both vars once at the top, like CLAUDE_CONFIG_DIR.
- The Codex SubagentStart branch (claude-codex-hooks.json is shared by both plugin
  manifests) had zero coverage. Added a codex-path assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1dMag33yz1Kf1jS24Aira

* fix: use PowerShell-safe command form for SubagentStart hook

Match the post-#265 'node ...; exit 0' form used by the sibling hooks. The old 'command -v node ... || exit 0' form fails tests/hooks-windows.test.js (POSIX-guard and non-blocking asserts) once this branch merges onto current main.

---------

Co-authored-by: Shane McCarron <shane.mccarron@corvexconnect.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-06-24 04:19:14 +02:00
Usul-Dev 9d0118df34 docs: add Korean README translation (#283)
* docs: add Korean README translation

Add README.ko.md following the README.es.md convention (community
translation note, English as canonical) and link it from README.md.

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

* docs(ko): sync translation with current README

Bring README.ko.md up to current English: npm install for OpenCode
(@dietrichgebert/ponytail) + npm badge, drop the obsolete ln -sf command-symlink
note (the plugin self-registers commands since #197), add the Swival section and
the Codex two-prompt install note.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Emeriko <dietrich.gebert@gmail.com>
2026-06-24 03:47:31 +02:00
DietrichGebert a0766a396d docs: npm install + badge for OpenCode, drop obsolete symlink note (#285)
The plugin registers the /ponytail commands itself now (#197), so the manual
ln -sf workaround is obsolete. Add the npm install snippet + version badge,
and bring README.es.md's OpenCode section up to parity with the English one.
2026-06-24 03:30:46 +02:00
salaamdev 731d3193e7 Merge branch 'main' into main 2026-06-19 05:58:49 +03:00
salaamdev b4f725f7ce Merge branch 'main' into main 2026-06-18 10:45:26 +03:00
salaamdev ce55fd460a Update author in plugin.yaml to Salaamdev 2026-06-15 23:13:09 +03:00
Abdisalam Hassan 4198fc30ac add Hermes plugin for Ponytail with command support and documentation 2026-06-15 19:57:45 +00:00
82 changed files with 2545 additions and 167 deletions
+1 -1
View File
@@ -25,6 +25,6 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.8.2",
"version": "4.9.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
+1 -1
View File
@@ -25,6 +25,6 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.8.2",
"version": "4.9.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
+1 -1
View File
@@ -31,6 +31,6 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+13
View File
@@ -0,0 +1,13 @@
{
"name": "ponytail",
"version": "4.9.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"homepage": "https://github.com/DietrichGebert/ponytail",
"repository": "https://github.com/DietrichGebert/ponytail",
"license": "MIT",
"keywords": ["yagni", "minimalism", "code-review", "productivity"]
}
+1 -1
View File
@@ -25,6 +25,6 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"version": "4.8.2",
"version": "4.9.0",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
+3
View File
@@ -23,6 +23,9 @@ jobs:
- name: Install Python deps for correctness checks
run: pip install pandas
- name: Install MCP deps
run: npm install --prefix ponytail-mcp
- name: Check rule copies
run: node scripts/check-rule-copies.js
+16
View File
@@ -0,0 +1,16 @@
{
"name": "ponytail",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"owner": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"plugins": [
{
"name": "ponytail",
"description": "Forces the laziest solution that works. YAGNI, stdlib first, one line over fifty.",
"source": "./",
"category": "productivity"
}
]
}
+1 -1
View File
@@ -30,6 +30,6 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+4 -2
View File
@@ -26,12 +26,14 @@ Level sticks until changed or session end.
|-------|---------|--------------|
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
| **ponytail-audit** | `/ponytail-audit` | Whole-repo over-engineering audit: ranked list of what to delete. |
| **ponytail-debt** | `/ponytail-debt` | Harvest `ponytail:` shortcut comments into a tracked ledger. |
| **ponytail-gain** | `/ponytail-gain` | Measured-impact scoreboard: less code, less cost, more speed. |
| **ponytail-help** | `/ponytail-help` | This card. |
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
`/ponytail-review`).
and OpenCode use the slash-command forms above (OpenCode ships all six as
slash commands).
## Deactivate
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: ponytail
description: "Lazy senior dev mode. Forces the simplest, shortest solution that works: YAGNI, stdlib first, no unrequested abstractions."
description: "Lazy senior dev mode for any coding task (write, refactor, fix, review): YAGNI, stdlib first, no unrequested abstractions. Not for non-coding requests."
homepage: https://github.com/DietrichGebert/ponytail
license: MIT
---
@@ -49,7 +49,7 @@ every sibling caller still broken. Fix it once, where all callers route through.
- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path (`# ponytail: global lock, per-account locks if throughput matters`).
## Output
+1 -1
View File
@@ -2,4 +2,4 @@
description: Quick reference for ponytail levels, skills, and commands
---
Show the ponytail quick reference. One shot, change nothing: do not switch mode, write flag files, or persist anything. Levels: /ponytail lite (build what's asked, name the lazier alternative in one line), /ponytail (full, the default ladder: YAGNI then stdlib then native then one line then minimum), /ponytail ultra (deletion before addition, challenges the requirement before building). Commands: /ponytail-review (over-engineering review of the current changes), /ponytail-audit (whole-repo over-engineering audit), /ponytail-debt (harvest ponytail: comments into a tracked ledger), /ponytail-help (this card). Deactivate with 'stop ponytail', 'normal mode', or /ponytail off; resume anytime with /ponytail. Default mode is full; change it with the PONYTAIL_DEFAULT_MODE environment variable (off|lite|full|ultra) or a config file at ~/.config/ponytail/config.json (Windows: %APPDATA%\ponytail\config.json) with {"defaultMode": "lite"}. Resolution order: env var, then config file, then full.
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.
+1 -1
View File
@@ -2,4 +2,4 @@
description: Switch ponytail intensity level (lite/full/ultra/off)
---
Switch to ponytail $ARGUMENTS mode. If no level specified, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment.
Switch to ponytail $ARGUMENTS mode. If no level specified, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark deliberate simplifications that cut a real corner with a known ceiling using a ponytail: comment that names the ceiling and upgrade path.
@@ -0,0 +1,23 @@
'use strict';
// ponytail command-file frontmatter parser.
//
// Pulled out of ponytail.mjs so the plugin module's only top-level export is
// the plugin function itself. OpenCode's legacy plugin loader (the one that
// runs before v1 plugins are detected) treats every function exported from a
// plugin module as a plugin; calling the frontmatter parser as one threw
// "path must be a string or a file descriptor" because it got the plugin
// context object as its first argument. Keeping the parser in its own module
// leaves exactly one plugin-shaped export on ponytail.mjs.
function parseCommandFile(filePath) {
const fs = require('fs');
const content = fs.readFileSync(filePath, 'utf8');
// Tolerate CRLF: a Windows checkout (autocrlf) delivers \r\n, npm ships \n.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return null;
const description = match[1].match(/description:\s*(.+)/)?.[1]?.trim();
return { description, template: match[2].trim() };
}
module.exports = { parseCommandFile };
+10 -11
View File
@@ -21,6 +21,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { getPonytailInstructions } = require('../../hooks/ponytail-instructions');
const { getDefaultMode, normalizePersistedMode } = require('../../hooks/ponytail-config');
const { parseCommandFile } = require('./ponytail-frontmatter.cjs');
// OpenCode has no flag-file convention of its own; keep mode beside its config.
const statePath = path.join(
@@ -42,15 +43,6 @@ function writeMode(mode) {
fs.writeFileSync(statePath, mode);
}
export function parseCommandFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
// Tolerate CRLF: a Windows checkout (autocrlf) delivers \r\n, npm ships \n.
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return null;
const description = match[1].match(/description:\s*(.+)/)?.[1]?.trim();
return { description, template: match[2].trim() };
}
export default async ({ client } = {}) => {
const log = (level, message) => {
try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {}
@@ -82,7 +74,12 @@ export default async ({ client } = {}) => {
'experimental.chat.system.transform': async (_input, output) => {
const mode = readMode();
if (mode === 'off') return;
output.system.push(getPonytailInstructions(mode));
const instructions = getPonytailInstructions(mode);
if (output.system.length > 0) {
output.system[output.system.length - 1] += '\n\n' + instructions;
} else {
output.system.push(instructions);
}
},
// Persist `/ponytail <level>` so the next turn's injection follows it.
@@ -92,7 +89,9 @@ export default async ({ client } = {}) => {
'command.execute.before': async (input) => {
if (!input || input.command !== 'ponytail') return;
// `off` is persisted like any mode; the transform reads it and stays silent.
const mode = normalizePersistedMode((input.arguments || '').trim()) || getDefaultMode();
const args = String(input.arguments || '').trim();
const mode = args ? normalizePersistedMode(args) : getDefaultMode();
if (!mode) return;
writeMode(mode);
log('info', 'ponytail ' + mode);
},
+16
View File
@@ -0,0 +1,16 @@
{
"name": "ponytail",
"version": "4.9.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"homepage": "https://github.com/DietrichGebert/ponytail",
"repository": "https://github.com/DietrichGebert/ponytail",
"license": "MIT",
"keywords": ["yagni", "minimalism", "code-review", "productivity"],
"skills": "./skills/",
"rules": "./.qoder/rules/",
"hooks": "./hooks/qoder-hooks.json"
}
+30
View File
@@ -0,0 +1,30 @@
# 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 it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
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.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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 deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+1 -1
View File
@@ -25,6 +25,6 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+1 -1
View File
@@ -25,7 +25,7 @@ Rules:
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- 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.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), 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.
+75 -5
View File
@@ -11,16 +11,27 @@
<em>No dice nada. Escribe una línea. Funciona.</em>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50668?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/50668" alt="DietrichGebert%2Fponytail | Trendshift" width="250" height="55"/></a>
</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/npm/v/@dietrichgebert/ponytail?style=flat-square&color=111111&label=npm" alt="npm">
<img src="https://img.shields.io/badge/funciona%20con-15%20agentes-111111?style=flat-square" alt="Works with 15 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%) &middot; ~20% más barato &middot; ~27% más rápido &middot; 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> &middot; <a href="benchmarks/">reprodúcelo</a>.</sub>
<a href="https://trendshift.io/repositories/50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/daily" alt="DietrichGebert/ponytail | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/weekly" alt="DietrichGebert/ponytail | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/50668?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/monthly?language=JavaScript" alt="DietrichGebert%2Fponytail | Trendshift monthly ranking" width="250" height="55"/></a>
</p>
<p align="center">
<strong>~54% menos de código (hasta 94%) &middot; ~20% más barato &middot; ~27% más rápido &middot; 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 simple 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> &middot; <a href="benchmarks/">reprodúcelo</a>.</sub>
</p>
<p align="center">
@@ -29,6 +40,10 @@
---
<p align="center">
<a href="https://ponytail.dev/soon"><img src="assets/waitlist-banner-es.png" alt="Algo nuevo está por llegar, únete a la lista" width="760"></a>
</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.
@@ -150,7 +165,13 @@ 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`:
Agrega esto a `opencode.json`:
```json
{ "plugin": ["@dietrichgebert/ponytail"] }
```
O ejecútalo desde un checkout (el plugin reutiliza sus `hooks/` y `skills/`):
```json
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] }
@@ -182,6 +203,14 @@ Reutiliza el `gemini-extension.json` de este repo. Una diferencia: Antigravity c
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.
### Devin CLI
```bash
devin plugins install DietrichGebert/ponytail
```
Instala ponytail como plugin de Devin; los skills quedan disponibles como `/ponytail:ponytail`, `/ponytail:ponytail-review`, etc.
### OpenClaw
```bash
@@ -190,6 +219,23 @@ 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/`.
### Grok Build
```bash
grok plugin install DietrichGebert/ponytail --trust
```
Habilita el plugin (está desactivado por defecto): `/plugins` → Plugins → Space en `ponytail`, o en `~/.grok/config.toml`:
```toml
[plugins]
enabled = ["ponytail"]
```
Abre una sesión nueva (o recarga los plugins). Los skills aparecen como `/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, `/ponytail-help`. Verifica con `grok inspect`. Grok puede invocar ponytail automáticamente en tareas de código según la descripción del skill; usa `/ponytail` (o `/ponytail lite`, `/ponytail full`, `/ponytail ultra`) cuando necesites activarlo de forma explícita. No se usan hooks de ciclo de vida de Grok: la salida de `SessionStart` no puede inyectar instrucciones.
`AGENTS.md` sigue funcionando solo como instrucciones desde un checkout sin el plugin. Desinstalar: `grok plugin uninstall ponytail`.
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.
@@ -216,7 +262,7 @@ Qué archivos corresponden a qué agente: [Portabilidad de agentes](docs/agent-p
| `/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.
Los comandos requieren un host compatible con skills (Claude Code, Codex, Devin CLI, OpenCode, Gemini, pi, Swival). 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
@@ -233,6 +279,9 @@ El benchmark de correctness lanza Python para las verificaciones de email y CSV;
## FAQ
**¿Puedo usarlo junto con [caveman](https://github.com/JuliusBrussee/caveman)?**
Sí, y deberías. Caveman achica lo que el agente dice; ponytail achica lo que construye. Mitades distintas, sin solapamiento: caveman deja el código intacto byte por byte, ponytail no se mete con la prosa. Charla concisa sobre código mínimo.
**¿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.
@@ -245,6 +294,27 @@ El código que nunca escribiste escala infinitamente. Cero bugs, cero CVEs, 100%
**¿Por qué "ponytail"?**
Ya sabes exactamente por qué.
## Patrocinadores
<p align="center">
<a href="https://greenpt.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-greenpt-dark.svg">
<img src="assets/logo-greenpt.svg" width="260" alt="GreenPT">
</picture>
</a>
</p>
## Licencia
[MIT](LICENSE). La licencia más corta que funciona.
## Historial de estrellas
<a href="https://www.star-history.com/dietrichgebert/ponytail#history">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date" />
</picture>
</a>
+337
View File
@@ -0,0 +1,337 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.png">
<img src="assets/logo.png" width="220" alt="Ponytail, the lazy senior dev">
</picture>
</p>
<h1 align="center">Ponytail</h1>
<p align="center">
<em>말이 없다. 한 줄을 쓴다. 돌아간다.</em>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50668?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/50668" alt="DietrichGebert%2Fponytail | Trendshift" width="250" height="55"/></a>
</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/npm/v/@dietrichgebert/ponytail?style=flat-square&color=111111&label=npm" alt="npm">
<img src="https://img.shields.io/badge/works%20with-15%20agents-111111?style=flat-square" alt="Works with 15 agents">
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/daily" alt="DietrichGebert/ponytail | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/weekly" alt="DietrichGebert/ponytail | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/50668?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/monthly?language=JavaScript" alt="DietrichGebert%2Fponytail | Trendshift monthly ranking" width="250" height="55"/></a>
</p>
<p align="center">
<strong>코드 약 54% 감소(최대 94%) &middot; 약 20% 저렴 &middot; 약 27% 빠름 &middot; 100% 안전</strong><br>
<sub>실제 오픈소스 저장소(FastAPI + React)를 고치는 실제 Claude Code 세션에서, 스킬을 끈 같은 에이전트와 견줘 측정했다. 약 54%는 기능 작업 12건의 평균이다(Haiku 4.5, n=4). 에이전트가 과하게 짤 여지가 있는 곳(날짜 선택기)에선 94%까지 오르고, 코드가 이미 최소한인 곳에선 0에 가깝다. ponytail은 안전 가드를 하나도 빼놓지 않지만, 그냥 "한 줄로 써"라고만 시킨 프롬프트는 그중 하나를 놓친다. (예전 단발성 벤치마크는 80-94%를 단일 수치로 내세웠는데, 공정한 에이전트 기준선에 견주면 그건 평균이 아니라 작업별 상한이다.) <a href="benchmarks/results/2026-06-18-agentic.md">전체 보고서</a> &middot; <a href="benchmarks/">직접 재현하기</a>.</sub>
</p>
<p align="center">
<sub>커뮤니티 번역이다. 기준이 되는 최신 버전은 <a href="README.md">영어 README</a>다.</sub>
</p>
---
<p align="center">
<a href="https://ponytail.dev/soon"><img src="assets/waitlist-banner-ko.png" alt="새로운 것이 다가오고 있습니다, 대기자 명단 신청" width="760"></a>
</p>
이런 사람, 다들 알 거다. 긴 포니테일에 타원형 안경. 버전 관리 시스템보다 회사에 오래 있었다. 코드 쉰 줄을 들이밀면 잠깐 보더니, 말없이 한 줄로 바꿔 놓는다.
Ponytail은 그를 당신의 AI 에이전트 안에 앉혀 둔다.
## Before / after
날짜 선택기 하나 만들어 달라고 한다. 에이전트는 flatpickr를 깔고, 래퍼 컴포넌트를 짜고, 스타일시트를 붙이더니, 타임존 얘기를 꺼내기 시작한다.
ponytail이라면:
```html
<!-- ponytail: browser has one -->
<input type="date">
```
살아남은 것들이 더 궁금하다면 [examples/](examples/)로.
## Numbers
공정하게 재려면 실제 에이전트에게 실질적인 작업을 시켜 봐야 한다. 헤드리스 Claude Code 세션에게 [tiangolo의 full-stack-fastapi-template](https://github.com/fastapi/full-stack-fastapi-template)(진짜 FastAPI + React 저장소)을 맡기고, 남긴 `git diff`로 점수를 매겼다. 기능 티켓 12건, 같은 에이전트를 스킬만 켜고 끄며 비교, n=4, Haiku 4.5.
<p align="center">
<img src="assets/benchmark-agentic.svg" width="860" alt="Each arm as a percent of the no-skill baseline across LOC, tokens, cost and time (Haiku 4.5). ponytail is lowest on every metric (LOC 46%, tokens 78%, cost 80%, time 73%); caveman rises above 100% on tokens, cost and time; yagni-oneliner LOC 67%. Safety, separate adversarial tier: baseline, caveman and ponytail 100%, yagni-oneliner 95%.">
</p>
| 스킬 없는 기준선 대비 | LOC | tokens | cost | time | safe |
|---|--:|--:|--:|--:|--:|
| **ponytail** | **-54%** | **-22%** | **-20%** | **-27%** | **100%** |
| caveman (간결한 산문 대조군) | -20% | +7% | +3% | +2% | 100% |
| "YAGNI + one-liners" 프롬프트 | -33% | -14% | -21% | -30% | 95% |
모든 지표를 깎은 건 ponytail뿐이고, 그러면서 안전까지 온전히 지킨 것도 ponytail뿐이다. 깎이는 폭은 과잉 구현의 함정이 실제로 있는 곳에서 가장 크다. 컴포넌트 대신 네이티브 `<input>`으로 손이 가니 날짜 선택기는 404줄에서 23줄로, 색상 선택기는 287줄에서 23줄로 줄어든다. 반대로 이미 군더더기 없는 코드에선 거의 0이다. 전체 방법론, 작업별 표, 한계는 [benchmarks/results/2026-06-18-agentic.md](benchmarks/results/2026-06-18-agentic.md)에 있다.
<details>
<summary><strong>예전 단발성 수치 (격리된 생성)</strong></summary>
일상적인 작업 다섯 가지, 모델 셋, 비교군 셋(스킬 없음, [caveman](https://github.com/JuliusBrussee/caveman), ponytail), 10회 실행, 중앙값 기준. 프롬프트 하나에 응답 하나, 답변의 줄 수를 셌다:
<p align="center">
<img src="assets/benchmark-3model.svg" width="860" alt="Median lines of code per arm across Haiku, Sonnet and Opus">
</p>
여기선 **코드 80-94% 감소**가 나왔다. 다만 [#126](https://github.com/DietrichGebert/ponytail/issues/126)이 맞게 짚었듯, 스킬을 전혀 안 붙인 기준선 모델은 답변을 설명과 선택지로 부풀린다. 그래서 그 격차의 일부는 대화형 기준선이 만들어 낸 착시다. 위의 에이전트 수치가 그걸 바로잡은, 근거 있는 버전이다. 단발성 실행은 `npx promptfoo eval -c benchmarks/promptfooconfig.yaml`로 재현할 수 있다.
</details>
**규칙은 애초에 "토큰 최소화"가 아니었다.** 작업에 필요한 만큼만 쓰되, 검증·에러 처리·보안·접근성은 절대 덜어내지 않는다는 것이다. 코드가 작아지는 건 억지로 줄여서가 아니라 딱 그만큼만 필요해서다. 비용과 지연이 낮아지는 것도 단계를 충실히 밟는 모델에서나 부수적으로 딸려 오는 효과일 뿐이다. 그 단계를 고민하느라 사고 토큰을 쏟는 간결한 추론 모델은 오히려 거꾸로 갈 수도 있다(GPT-5.5가 그렇다).
## How it works
코드를 쓰기 전에, 에이전트는 가장 먼저 들어맞는 단계에서 멈춘다:
```
1. 이게 있을 필요가 있나? → 없다: 건너뛴다 (YAGNI)
2. 이미 이 코드베이스에 있나? → 다시 짜지 말고 가져다 쓴다
3. 표준 라이브러리로 되나? → 쓴다
4. 네이티브 플랫폼 기능인가? → 쓴다
5. 깔려 있는 의존성이 푸나? → 쓴다
6. 한 줄로 되나? → 한 줄
7. 그제서야: 돌아가는 최소한
```
단계를 밟는 건 문제를 이해한 *다음*이지, 이해를 대신하는 게 아니다. 변경이 닿는 코드를 읽고 실제 흐름을 따라가 본 뒤에야 단계를 고른다. 해법에는 게을러도, 읽는 데는 절대 게으르지 않다.
게으른 거지 부주의한 게 아니다. 신뢰 경계의 검증, 데이터 손실 방지, 보안, 접근성은 결코 잘려 나가지 않는다.
## Install
ponytail이 당신에게 요구할 수고의 최대치:
Claude Code와 Codex 플러그인은 자그마한 Node.js 라이프사이클 훅 두 개를 돌리니, `node`가 PATH에 잡혀 있어야 한다(Nix/nvm 사용자라면 비대화형 셸의 PATH에 있어야 한다). 없어도 스킬은 멀쩡히 돌아간다. 다만 늘 켜져 있던 자동 활성화가 매 프롬프트마다 에러를 뱉는 대신 조용히 비활성으로 남을 뿐이다.
### Claude Code
```
/plugin marketplace add DietrichGebert/ponytail
```
```
/plugin install ponytail@ponytail
```
(설치가 되려면 두 프롬프트를 따로 보내야 한다)
데스크톱 앱에는 `/plugin` 명령이 없다. 대신 UI에서 설치한다: Customize, 개인 플러그인 옆의 +, Create plugin and add marketplace, Add from repository, 그다음 저장소 URL 입력(감사합니다 @NiklasDHahn, #98).
### Codex
```bash
codex plugin marketplace add DietrichGebert/ponytail
codex
```
`/plugins`를 열어 Ponytail 마켓플레이스를 고르고 Ponytail을 설치한다. 그런 다음
`/hooks`를 열어 라이프사이클 훅 두 개를 검토하고 신뢰한 뒤, 새 스레드를 시작한다.
이 설치 한 번이면 Codex 데스크톱 앱도 같이 잡힌다. 설치 후 앱을 다시 켜면 플러그인을 알아챈다.
### GitHub Copilot CLI
```bash
copilot plugin marketplace add DietrichGebert/ponytail
copilot plugin install ponytail@ponytail
```
대화형 Copilot CLI 세션에서는 슬래시 명령으로 똑같이 하면 된다:
```
/plugin marketplace add DietrichGebert/ponytail
/plugin install ponytail@ponytail
```
Copilot CLI는 플러그인 명령에 그 이름을 네임스페이스로 붙인다. 예를 들면:
```text
/ponytail:ponytail ultra
/ponytail:ponytail-review
```
### Pi agent harness
```
pi install git:github.com/DietrichGebert/ponytail
```
### OpenCode
`opencode.json`에 다음을 더한다:
```json
{ "plugin": ["@dietrichgebert/ponytail"] }
```
체크아웃에서 직접 돌려도 된다(플러그인이 `hooks/``skills/`를 그대로 쓴다):
```json
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] }
```
매 턴마다 지금 레벨의 룰셋을 주입하고, `/ponytail` 명령들을 붙여 준다([Commands](#commands) 참고). OpenCode는 이 저장소의 `AGENTS.md`도 알아서 불러오니, 플러그인이 없어도 규칙은 살아 있다. 플러그인은 `lite/full/ultra/off` 레벨을 얹어 준다.
`./` 경로는 프로젝트의 `opencode.json`을 기준으로 풀린다. 체크아웃 하나를 여러 프로젝트에서 같이 쓰려면, 대신 `.mjs`의 절대 경로를 가리키면 된다(그 파일은 제 위치를 기준으로 `hooks/``skills/`를 찾는다).
### Gemini CLI
```bash
gemini extensions install https://github.com/DietrichGebert/ponytail
```
매 세션 룰셋을 늘 켜진 컨텍스트로 불러오고 `/ponytail` 명령들을 등록한다. `skills/`도 함께 실리며, 작업에 필요할 때 켜진다.
Gemini 어댑터는 일부러 루트 `hooks/hooks.json`을 두지 않는다. Gemini는 그 경로를 자동으로 불러오는데, ponytail의 라이프사이클 훅은 Claude/Codex 이벤트 이름을 쓰기 때문이다.
### Antigravity CLI
Google이 Gemini CLI를 Antigravity CLI(`agy` 바이너리)로 이름을 바꾸는 중인데, 같은 확장이 거기에도 설치된다:
```bash
agy plugin install https://github.com/DietrichGebert/ponytail
```
이 저장소의 `gemini-extension.json`을 그대로 재사용한다. 차이는 하나다. Antigravity는 `/ponytail` 명령들을 스킬로 바꿔 버려서, 슬래시 메뉴에서 고르는 대신 채팅에 직접 친다(예: `/ponytail-review`를 메시지로). 전환이 마무리될 때까지(2026년 6월 18일경)는 `gemini extensions install`도 여전히 먹힌다. 늘 켜진 규칙으로 돌리고 싶으면, 룰셋을 `.agents/rules/`에 넣으면 된다.
### CodeWhale
프로젝트 루트의 `AGENTS.md`를 읽고, 설정은 전혀 필요 없다. [`AGENTS.md`](AGENTS.md)를 프로젝트에 복사하거나, 이 저장소를 체크아웃한 곳에서 `codewhale`을 돌리면 된다. 그게 끝이다.
### Swival
먼저 컬렉션을 라이브러리에 스테이징한 다음, 원하는 스킬을 더한다:
```bash
swival skills add --global https://github.com/DietrichGebert/ponytail # ~/.config/swival/library에 스테이징
swival skills add ponytail # 이 프로젝트에 컬렉션 설치
swival skills add --global ponytail # 또는 모든 프로젝트에서 켜기
```
Swival도 프로젝트 루트의 `AGENTS.md`와 전역의 `~/.config/swival/AGENTS.md`를 읽는다. 지시문 전용 폴백이다.
명령줄에서는 `$` 접두사로 스킬을 명시적으로 켠다. 예: `$ponytail-review`.
### Devin CLI
```bash
devin plugins install DietrichGebert/ponytail
```
ponytail을 Devin 플러그인으로 설치한다. 스킬은 `/ponytail:ponytail`, `/ponytail:ponytail-review` 등으로 쓸 수 있다.
### OpenClaw
```bash
clawhub install ponytail
```
ClawHub에서 ponytail을 OpenClaw 스킬로 설치한다. review, audit, debt, gain, help 스킬도 같은 식으로 깐다(`clawhub install ponytail-review` 등). OpenClaw는 코딩 작업에 이를 적용하고 `/ponytail` 명령으로도 열어 준다. ClawHub가 없으면 [`.openclaw/skills/ponytail`](.openclaw/skills/)을 `~/.openclaw/skills/`에 복사하면 된다.
### Grok Build
```bash
grok plugin install DietrichGebert/ponytail --trust
```
플러그인은 기본이 꺼져 있다. `/plugins` → Plugins에서 `ponytail`에 Space, 또는 `~/.grok/config.toml`:
```toml
[plugins]
enabled = ["ponytail"]
```
새 세션을 열거나 플러그인을 다시 로드한다. 스킬은 `/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, `/ponytail-help`로 보인다. `grok inspect`로 확인. Grok은 스킬 설명을 바탕으로 코딩 작업에서 ponytail을 자동으로 호출할 수 있다. 명시적으로 활성화해야 하면 `/ponytail`(또는 `/ponytail lite`, `/ponytail full`, `/ponytail ultra`)을 사용한다. `SessionStart` 출력으로는 지시문을 주입할 수 없으므로 Grok 라이프사이클 훅은 사용하지 않는다.
체크아웃의 `AGENTS.md`만으로도 지시문 전용 모드는 된다. 제거: `grok plugin uninstall ponytail`.
이게 끝이었다. 그 사람이라면 흐뭇해할 거다. 입 밖으로 내진 않겠지만.
매 세션 켜져 있고, 명령 몇 개가 딸려 온다([Commands](#commands) 참고). `/ponytail ultra`는 코드베이스가 당신에게 단단히 밉보인 날을 위해 있다. 시작할 때와 모드를 바꿀 때 지금 모드를 보여 준다.
새 세션마다 적용할 레벨은 `PONYTAIL_DEFAULT_MODE` 환경 변수(`lite`/`full`/`ultra`/`off`)로, 또는 `~/.config/ponytail/config.json``defaultMode` 필드(Windows에선 `%APPDATA%\ponytail\config.json`)로 정한다. 기본값은 `full`이다.
Cursor, Windsurf, Cline, GitHub Copilot(에디터), Aider, Kiro, Zed, CodeWhale: 이 저장소에서 맞는 규칙 파일을 복사하면 된다([`.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: `.kiro/steering/ponytail.md``~/.kiro/steering/`(전역)이나 프로젝트의 `.kiro/steering/`에 복사한다.
GitHub Copilot CLI 폴백(지시문 전용 모드): 프로젝트의 `AGENTS.md``.github/copilot-instructions.md`를 읽거나, 모든 프로젝트에서 ponytail을 돌리려면 규칙을 `~/.copilot/copilot-instructions.md`에 복사한다. 이 경로는 늘 켜진 가이드는 살리지만, 플러그인 모드 전환이나 훅은 더해 주지 않는다.
Codex 확장을 쓰는 VS Code는 이 저장소가 함께 싣는 `AGENTS.md`를 읽으니, 저장소 루트에서 설정 없이 돌아간다(`~/.codex/AGENTS.md`를 두면 Codex 전역으로 잡힌다).
어떤 파일이 어느 에이전트에 매핑되는지: [Agent portability](docs/agent-portability.md).
## Commands
| 명령 | 하는 일 |
|---------|--------------|
| `/ponytail [lite \| full \| ultra \| off]` | 강도를 정하거나, 끈다. 인수가 없으면 지금 레벨을 알려 준다. |
| `/ponytail-review` | 지금 diff를 과잉 구현 관점에서 훑고, 삭제 목록을 돌려준다. |
| `/ponytail-audit` | diff만이 아니라 저장소 전체를 과잉 구현 관점에서 감사한다. |
| `/ponytail-debt` | 미뤄 둔 `ponytail:` 간소화들을 장부로 모아, "나중에"가 "영영"이 되지 않게 한다. |
| `/ponytail-gain` | 벤치마크로 잰 효과 스코어보드(코드 절감, 비용 절감, 속도 향상)를 보여 준다. |
| `/ponytail-help` | 위 명령들의 빠른 참조. |
명령들은 스킬을 지원하는 호스트가 있어야 돈다(Claude Code, Codex, Devin CLI, OpenCode, Gemini, pi, Swival). Codex에선 스킬이라 `@`로 부른다(`@ponytail-review`). 지시문 전용 어댑터(Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity)는 명령 없이 늘 켜진 룰셋만 불러온다.
## Development
압축 규칙 텍스트를 바꿀 때는, 에이전트 사본들을 같은 상태로 맞춰 둔다:
```bash
node scripts/check-rule-copies.js
npm test
```
OpenClaw 스킬 패키지(`.openclaw/skills/`)는 `skills/`에서 생성된다. 스킬을 바꾼 뒤에는 `node scripts/build-openclaw-skills.js`를 다시 돌린다. 묵은 상태면 테스트 스위트가 실패한다.
정확성 벤치마크는 이메일·CSV 검사를 위해 Python을 띄운다. `python`보다 `python3`를 먼저 시도한다. CSV 검사는 로컬에 `pandas`가 깔려 있어야 한다.
## FAQ
**설정 파일이 필요한가?**
아니다. 선택 사항인 `~/.config/ponytail/config.json`이나 `PONYTAIL_DEFAULT_MODE` 환경 변수로 기본 레벨을 정할 순 있지만, 꼭 있어야 하는 건 없다.
**그래도 120줄짜리 캐시 클래스가 정말 필요하다면?**
필요 없다. 그래도 우기면 그가 만들어 준다. 천천히. 정확하게. 당신을 쳐다보면서.
**확장은 되나?**
당신이 안 쓴 코드는 무한히 확장된다. 버그 0, CVE 0, 가동률 100%. 예나 지금이나.
**왜 하필 "ponytail"인가?**
당신은 이유를 정확히 안다.
## Sponsors
<p align="center">
<a href="https://greenpt.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-greenpt-dark.svg">
<img src="assets/logo-greenpt.svg" width="260" alt="GreenPT">
</picture>
</a>
</p>
## License
[MIT](LICENSE). 돌아가는 가장 짧은 라이선스.
## Star History
<a href="https://www.star-history.com/dietrichgebert/ponytail#history">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date" />
</picture>
</a>
+107 -11
View File
@@ -11,24 +11,50 @@
<em>He says nothing. He writes one line. It works.</em>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50668?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/50668" alt="DietrichGebert%2Fponytail | Trendshift" width="250" height="55"/></a>
</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/works%20with-14%20agents-111111?style=flat-square" alt="Works with 14 agents">
<img src="https://img.shields.io/npm/v/@dietrichgebert/ponytail?style=flat-square&color=111111&label=npm" alt="npm">
<img src="https://img.shields.io/badge/works%20with-20%20agents-111111?style=flat-square" alt="Works with 20 agents">
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/daily" alt="DietrichGebert/ponytail | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/weekly" alt="DietrichGebert/ponytail | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/50668?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-50668" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50668/monthly?language=JavaScript" alt="DietrichGebert%2Fponytail | Trendshift monthly ranking" width="250" height="55"/></a>
</p>
<p align="center">
<strong>~54% less code (up to 94%) &middot; ~20% cheaper &middot; ~27% faster &middot; 100% safe</strong><br>
<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> &middot; <a href="benchmarks/">reproduce it</a>.</sub>
</p>
<p align="center">
<sub><a href="README.es.md">Español</a></sub>
<sub><a href="README.es.md">Español</a> &middot; <a href="README.ko.md">한국어</a></sub>
</p>
---
<p align="center">
<a href="https://ponytail.dev/soon"><img src="assets/waitlist-banner.png" alt="Something's coming, join the waitlist" width="760"></a>
</p>
## Already built with Ponytail
<a href="https://theretriever.app">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/retriever-logo-dark.svg">
<img src="assets/retriever-logo-light.svg" height="128" alt="Retriever">
</picture>
</a>
---
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.
Ponytail puts him inside your AI agent.
@@ -111,17 +137,16 @@ The Claude Code and Codex plugins run two tiny Node.js lifecycle hooks, so `node
```
(You have to send two separate prompts for the install to work)
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).
Same steps in the Claude Code Desktop app's Code tab: type the two `/plugin` commands above into the prompt box, or click the **+** button next to it, choose **Plugins****Add plugin** to browse your configured marketplaces, and manage marketplaces from **Customize** in the sidebar.
### Codex
```bash
codex plugin marketplace add DietrichGebert/ponytail
codex
codex plugin add ponytail@ponytail
```
Open `/plugins`, select the Ponytail marketplace, and install Ponytail. Then
open `/hooks`, review and trust its two lifecycle hooks, and start a new thread.
Run `codex` and open `/hooks`, review and trust its two lifecycle hooks, and start a new thread.
This same install also covers the Codex desktop app: restart the app after installing and it picks up the plugin.
@@ -170,8 +195,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 plugin path loads the ruleset everywhere, but the `/ponytail` commands are separate files in `.opencode/command/` that OpenCode only discovers from your project or the global commands dir. To use them outside this checkout, link them once: `ln -sf /absolute/path/to/ponytail/.opencode/command/* ~/.config/opencode/command/`.
### Gemini CLI
```bash
@@ -181,6 +204,12 @@ 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.
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.
### Qoder
Qoder auto-loads `AGENTS.md` from the repo root as always-on context, so running ponytail from a checkout works with zero setup. For per-project rules, copy [`.qoder/rules/ponytail.md`](.qoder/rules/ponytail.md) into your project's `.qoder/rules/`. The six ponytail skills (`/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, `/ponytail-help`) are available via Qoder's Skill system; the plugin manifest at [`.qoder-plugin/plugin.json`](.qoder-plugin/plugin.json) points at the `skills/` directory.
For full plugin-tier support (automatic mode activation + ruleset injection on every prompt), add the hooks from [`hooks/qoder-hooks.json`](hooks/qoder-hooks.json) to your `.qoder/settings.json`. Replace `PONYTAIL_DIR` with the path to your ponytail checkout. Qoder's `UserPromptSubmit` hook activates the default mode on first prompt and injects the ruleset every turn; `PreToolUse` with `task|Task` matcher injects the ruleset into subagents. Level switches (`/ponytail lite|full|ultra|off`) work automatically.
### Antigravity CLI
Google is renaming Gemini CLI to Antigravity CLI (the `agy` binary); the same extension installs there:
@@ -191,6 +220,14 @@ 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/`.
### Hermes Agent
```bash
hermes plugins install DietrichGebert/ponytail --enable
```
Restart Hermes after installing. The plugin injects the active Ponytail mode before each LLM turn, registers the bundled skills as `ponytail:<skill>`, and adds `/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, and `/ponytail-help`. In shared gateways, restrict `/ponytail` to trusted users with Hermes slash-command access controls; runtime mode is process-local.
### 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.
@@ -209,6 +246,14 @@ Swival also reads `AGENTS.md` from the project root and `~/.config/swival/AGENTS
On the command line, use a `$` prefix to explicitly activate a skill. For example: `$ponytail-review`.
### Devin CLI
```bash
devin plugins install DietrichGebert/ponytail
```
Installs ponytail as a Devin plugin; skills are available as `/ponytail:ponytail`, `/ponytail:ponytail-review`, and so on.
### OpenClaw
```bash
@@ -217,13 +262,32 @@ 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/`.
### Grok Build
```bash
grok plugin install DietrichGebert/ponytail --trust
```
Enable the plugin (off by default): `/plugins` → Plugins → Space on `ponytail`, or in `~/.grok/config.toml`:
```toml
[plugins]
enabled = ["ponytail"]
```
Start a new session (or reload plugins). Skills show as `/ponytail`, `/ponytail-review`, `/ponytail-audit`, `/ponytail-debt`, `/ponytail-gain`, `/ponytail-help`. Verify with `grok inspect`. Grok can auto-invoke ponytail for coding tasks from its skill description; use `/ponytail` (or `/ponytail lite`, `/ponytail full`, `/ponytail ultra`) when activation needs to be explicit. Grok lifecycle hooks are not used because their SessionStart output cannot inject instructions.
`AGENTS.md` still works instruction-only from a checkout without the plugin.
That was it. He'd be proud. He won't say it.
Active every session, with a handful of commands (see [Commands](#commands)). `/ponytail ultra` exists for when the codebase has wronged you personally. Startup and mode-change text shows the current mode.
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, Swival: 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/)).
While active, the ruleset is also injected into every subagent spawned via the Agent tool. To scope that to specific agent types (say, keep it off read-only search agents), set the `PONYTAIL_SUBAGENT_MATCHER` env var to a regex tested against the subagent's `agent_type`. It is unanchored and case-insensitive: `explore|general` matches either, `^general$` is exact, and plugin agent types look like `plugin:name`. Unset means inject into every subagent (the default); an invalid regex, or a subagent whose type the platform doesn't report, also falls back to injecting.
Cursor, Windsurf, Cline, GitHub Copilot Chat (the VS Code, JetBrains, and Visual Studio editor extension, not the standalone Copilot CLI covered under [Install](#install)), Aider, Kiro, Zed, CodeWhale, Swival, Qoder: 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/), [`.qoder/rules/`](.qoder/rules/)).
Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project.
@@ -231,6 +295,12 @@ GitHub Copilot CLI fallback (instruction-only mode): it reads `AGENTS.md` and `.
VS Code with the Codex extension reads `AGENTS.md`, which this repo ships, so it works from the repo root with no setup (`~/.codex/AGENTS.md` makes Codex global).
JetBrains Junie can read `AGENTS.md` once you point it there in Settings → Tools → Junie → Project Settings → Guidelines Path (it is not automatic yet). This repo ships `AGENTS.md`; `.junie/guidelines.md` is Junie's legacy path.
Amp (Sourcegraph) reads `AGENTS.md` from the working directory and parent directories up to `$HOME`, which this repo ships, so it works with no setup (`~/.config/amp/AGENTS.md` works globally).
Jules (Google) reads `AGENTS.md` from the repository root, which this repo ships, so it picks up the ruleset with no setup.
Which files map to which agent: [Agent portability](docs/agent-portability.md).
### Uninstall
@@ -239,8 +309,10 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md).
|------|---------|
| Claude Code | `/plugin remove ponytail` |
| Codex | `codex plugin remove ponytail` |
| Devin CLI | `devin plugins remove ponytail` |
| Grok Build | `grok plugin uninstall ponytail` |
| Pi agent | `pi uninstall ponytail` |
| Cursor / Windsurf / Cline / etc. | Delete the copied rule file |
| Cursor / Windsurf / Cline / Qoder / etc. | Delete the copied rule file |
These remove the plugin's own files. They leave behind a small amount of state ponytail writes outside the plugin folder: the mode flag, `~/.config/ponytail/config.json`, and (if you accepted the setup nudge) a `statusLine` entry in `~/.claude/settings.json`. Run `node scripts/uninstall.js` to clean those up too. **Run it before the host remove command above** — the script is itself a plugin file, so removing the plugin first deletes it (or run it from a separate clone of this repo). It only removes the statusLine entry if it points at ponytail's own script, so a statusline you set up yourself is left untouched.
@@ -255,7 +327,7 @@ These remove the plugin's own files. They leave behind a small amount of state p
| `/ponytail-gain` | Show the measured impact scoreboard (less code, less cost, more speed) from the benchmark. |
| `/ponytail-help` | Quick reference for the commands above. |
Commands need a skill-capable host (Claude Code, Codex, OpenCode, Gemini, pi, Swival). 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, Devin CLI, OpenCode, Gemini, pi, Swival, Hermes Agent, Qoder, Grok Build). 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.
## Development
@@ -272,6 +344,9 @@ The correctness benchmark spawns Python for email and CSV checks; `python3` is t
## FAQ
**Can I use it with [caveman](https://github.com/JuliusBrussee/caveman)?**
Yes, and you should. Caveman shrinks what the agent says; ponytail shrinks what it builds. Different halves, no overlap: caveman leaves code byte-for-byte exact, ponytail stays out of the prose. Terse talk about minimal code.
**Does it need a config file?**
No. An optional `~/.config/ponytail/config.json` or `PONYTAIL_DEFAULT_MODE` env var can set the default level, but nothing is required.
@@ -284,6 +359,27 @@ The code you never wrote scales infinitely. Zero bugs, zero CVEs, 100% uptime si
**Why "ponytail"?**
You know exactly why.
## Sponsors
<p align="center">
<a href="https://greenpt.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-greenpt-dark.svg">
<img src="assets/logo-greenpt.svg" width="260" alt="GreenPT">
</picture>
</a>
</p>
## License
[MIT](LICENSE). The shortest license that works.
## Star History
<a href="https://www.star-history.com/dietrichgebert/ponytail#history">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=DietrichGebert/ponytail&type=Date" />
</picture>
</a>
+217
View File
@@ -0,0 +1,217 @@
"""Hermes plugin for Ponytail."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any, Callable
DEFAULT_MODE = "full"
RUNTIME_MODES = {"off", "lite", "full", "ultra"}
CONFIG_MODES = RUNTIME_MODES | {"review"}
SKILL_COMMANDS = {
"ponytail-review": "Review the current diff or provided target for over-engineering.",
"ponytail-audit": "Audit the repo for over-engineering and deletion opportunities.",
"ponytail-debt": "List every deliberate `ponytail:` shortcut and its upgrade path.",
"ponytail-gain": "Show the measured-impact scoreboard (less code, less cost, more speed).",
"ponytail-help": "Show the Ponytail command reference.",
}
ROOT = Path(__file__).resolve().parent
SKILLS_DIR = ROOT / "skills"
PONYTAIL_SKILL = SKILLS_DIR / "ponytail" / "SKILL.md"
REVIEW_SKILL = SKILLS_DIR / "ponytail-review" / "SKILL.md"
_current_mode = None
def _normalize_runtime_mode(mode: str | None) -> str | None:
if not isinstance(mode, str):
return None
mode = mode.strip().lower()
return mode if mode in RUNTIME_MODES else None
def _normalize_config_mode(mode: str | None) -> str | None:
if not isinstance(mode, str):
return None
mode = mode.strip().lower()
return mode if mode in CONFIG_MODES else None
def _config_dir() -> Path:
if os.environ.get("XDG_CONFIG_HOME"):
return Path(os.environ["XDG_CONFIG_HOME"]) / "ponytail"
if os.name == "nt":
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "ponytail"
return Path.home() / ".config" / "ponytail"
def _default_mode() -> str:
env_mode = _normalize_config_mode(os.environ.get("PONYTAIL_DEFAULT_MODE"))
if env_mode:
return env_mode
try:
data = json.loads((_config_dir() / "config.json").read_text(encoding="utf-8"))
file_mode = _normalize_config_mode(data.get("defaultMode"))
if file_mode:
return file_mode
except Exception:
pass
return DEFAULT_MODE
def _strip_frontmatter(text: str) -> str:
return re.sub(r"^---[\s\S]*?---\s*", "", text or "", count=1)
def _filter_skill_body_for_mode(body: str, mode: str) -> str:
effective = _normalize_runtime_mode(mode) or DEFAULT_MODE
lines = []
for line in _strip_frontmatter(body).splitlines():
table_label = re.match(r"^\|\s*\*\*(.+?)\*\*\s*\|", line)
if table_label:
label_mode = _normalize_runtime_mode(table_label.group(1))
if label_mode and label_mode != effective:
continue
example_label = re.match(r"^-\s*([^:]+):\s*", line)
if example_label:
label_mode = _normalize_runtime_mode(example_label.group(1))
if label_mode and label_mode != effective:
continue
lines.append(line)
return "\n".join(lines)
def _fallback_instructions(mode: str) -> str:
return (
f"PONYTAIL MODE ACTIVE — level: {mode}\n\n"
"You are a lazy senior developer. Lazy means efficient, not careless. "
"The best code is the code never written.\n\n"
"Before any code, stop at the first rung that holds: YAGNI, stdlib, "
"native platform, installed dependency, one line, then minimum code. "
"No unrequested abstractions, avoidable dependencies, boilerplate, or "
"speculative scaffolding. Deletion over addition. Boring over clever. "
"Do not simplify away trust-boundary validation, data-loss handling, "
"security, accessibility, explicitly requested behavior, or one small "
"runnable check for non-trivial logic."
)
def build_injected_context(mode: str | None = None) -> str:
"""Return the mode-filtered Ponytail context injected before LLM turns."""
configured = _normalize_config_mode(mode) or _default_mode()
if configured == "off":
return ""
if configured == "review":
try:
body = REVIEW_SKILL.read_text(encoding="utf-8")
return f"PONYTAIL MODE ACTIVE — level: review\n\n{_strip_frontmatter(body)}"
except OSError:
return "PONYTAIL MODE ACTIVE — level: review. Review diffs for unnecessary complexity."
effective = _normalize_runtime_mode(configured) or DEFAULT_MODE
try:
body = PONYTAIL_SKILL.read_text(encoding="utf-8")
return f"PONYTAIL MODE ACTIVE — level: {effective}\n\n{_filter_skill_body_for_mode(body, effective)}"
except OSError:
return _fallback_instructions(effective)
def _pre_llm_call(session_id: str = "", **_: Any) -> dict[str, str] | None:
mode = _current_mode or _default_mode()
context = build_injected_context(mode)
return {"context": context} if context else None
def _skill_prompt(command: str, args: str = "") -> str:
tail = args.strip()
target = f"\n\nUser arguments: {tail}" if tail else ""
return (
f"Load and follow the Hermes plugin skill `ponytail:{command}`. "
f"{SKILL_COMMANDS[command]}{target}"
)
def _slash_access_denied(event: Any, gateway: Any, command: str) -> bool:
if gateway is None or event is None:
return False
checker = getattr(gateway, "_check_slash_access", None)
source = getattr(event, "source", None)
if checker is None or source is None:
return False
try:
return checker(source, command) is not None
except Exception:
return True
def rewrite_gateway_command(event: Any = None, gateway: Any = None, **_: Any) -> dict[str, str] | None:
"""Rewrite authorized gateway /ponytail-* commands into normal agent prompts."""
text = str(getattr(event, "text", "") or "").strip()
if not text.startswith("/"):
return None
head, _, rest = text[1:].partition(" ")
command = head.replace("_", "-").lower()
if command not in SKILL_COMMANDS:
return None
if _slash_access_denied(event, gateway, command):
return None
return {"action": "rewrite", "text": _skill_prompt(command, rest)}
def _handle_mode_command(raw_args: str) -> str:
global _current_mode
arg = (raw_args or "").strip().lower()
if not arg:
mode = _current_mode or _default_mode()
return f"Ponytail mode: {mode}. Use `/ponytail lite|full|ultra|off`."
mode = _normalize_runtime_mode(arg)
if not mode:
return "Usage: /ponytail [lite|full|ultra|off]"
_current_mode = mode
return f"Ponytail mode set to {mode}."
def _make_skill_command_handler(ctx: Any, command: str) -> Callable[[str], str]:
def handler(raw_args: str) -> str:
prompt = _skill_prompt(command, raw_args or "")
injected = False
try:
injected = bool(ctx.inject_message(prompt))
except Exception:
injected = False
if injected:
return f"Queued `{command}` for the agent."
return prompt
return handler
def register(ctx: Any) -> None:
"""Register Ponytail hooks, skills, and slash commands with Hermes."""
for child in sorted(SKILLS_DIR.iterdir() if SKILLS_DIR.exists() else []):
skill_md = child / "SKILL.md"
if child.is_dir() and skill_md.exists():
ctx.register_skill(child.name, skill_md)
ctx.register_hook("pre_llm_call", _pre_llm_call)
ctx.register_hook("pre_gateway_dispatch", rewrite_gateway_command)
ctx.register_command(
"ponytail",
_handle_mode_command,
description="Set Ponytail lazy senior dev mode: lite, full, ultra, or off.",
args_hint="[lite|full|ultra|off]",
)
for command, description in SKILL_COMMANDS.items():
ctx.register_command(
command,
_make_skill_command_handler(ctx, command),
description=description,
args_hint="[target or notes]",
)
+22
View File
@@ -0,0 +1,22 @@
# Ponytail for Hermes installed
Enable it if you did not install with `--enable`:
```bash
hermes plugins enable ponytail
```
Restart Hermes or the gateway after enabling.
In shared gateways, restrict `/ponytail` to trusted users with Hermes slash-command access controls; runtime mode is process-local.
Commands:
- `/ponytail [lite|full|ultra|off]`
- `/ponytail-review [target]`
- `/ponytail-audit [target]`
- `/ponytail-debt`
- `/ponytail-gain`
- `/ponytail-help`
Bundled skills are available as `ponytail:ponytail`, `ponytail:ponytail-review`, `ponytail:ponytail-audit`, `ponytail:ponytail-debt`, `ponytail:ponytail-gain`, and `ponytail:ponytail-help`.
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="394px" height="86px" viewBox="0 0 394 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>logo-greengpt-white</title>
<defs>
<linearGradient x1="46.5850926%" y1="13.9833492%" x2="59.2369668%" y2="85.5111279%" id="linearGradient-1">
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"></stop>
<stop stop-color="#000000" stop-opacity="0.3" offset="100%"></stop>
</linearGradient>
</defs>
<g id="logo-greengpt-white" stroke="none" fill="none">
<g id="GreenPT" stroke-width="1" fill-rule="evenodd" transform="translate(108, 18)" fill="#FFFFFF">
<path d="M50,22.2527473 L25.7261411,22.2527473 L25.7261411,31.8681319 L38.6583679,31.8681319 C36.9294606,36.4697802 32.7109267,39.2857143 26.2793914,39.2857143 C16.8741355,39.2857143 11.0650069,33.3104396 11.0650069,25.1373626 C11.0650069,16.6895604 17.1507607,10.7142857 25.3112033,10.7142857 C30.7745505,10.7142857 35.131397,13.1868132 37.1369295,16.3461538 L46.5421853,10.989011 C42.461964,4.53296703 34.6473029,0 25.3803596,0 C11.1341632,0 0,11.1263736 0,25.0686813 C0,38.8736264 10.9266943,50 26.1410788,50 C39.6957123,50 50,41.0714286 50,26.3736264 L50,22.2527473 Z" id="Path" fill-rule="nonzero"></path>
<path d="M65.5,20.7941176 L65.5,14.6862745 L55,14.6862745 L55,49 L65.5,49 L65.5,33.4901961 C65.5,26.6960784 71.66,24.9803922 76,25.6666667 L76,14 C71.59,14 66.9,16.1960784 65.5,20.7941176 Z" id="Path" fill-rule="nonzero"></path>
<path d="M89.0076923,36.0909091 L113.584615,36.0909091 C113.861538,34.7954545 114,33.4318182 114,32 C114,21.7045455 106.523077,14 96.4153846,14 C85.4769231,14 78,21.8409091 78,32 C78,42.1590909 85.3384615,50 97.1769231,50 C103.753846,50 108.876923,47.6136364 112.269231,42.9772727 L103.961538,38.2727273 C102.576923,39.7727273 100.153846,40.8636364 97.3153846,40.8636364 C93.5076923,40.8636364 90.3230769,39.6363636 89.0076923,36.0909091 Z M88.8,28.4545455 C89.7692308,24.9772727 92.4,23.0681818 96.3461538,23.0681818 C99.4615385,23.0681818 102.576923,24.5 103.684615,28.4545455 L88.8,28.4545455 Z" id="Shape" fill-rule="nonzero"></path>
<path d="M128.007692,36.0909091 L152.584615,36.0909091 C152.861538,34.7954545 153,33.4318182 153,32 C153,21.7045455 145.523077,14 135.415385,14 C124.476923,14 117,21.8409091 117,32 C117,42.1590909 124.338462,50 136.176923,50 C142.753846,50 147.876923,47.6136364 151.269231,42.9772727 L142.961538,38.2727273 C141.576923,39.7727273 139.153846,40.8636364 136.315385,40.8636364 C132.507692,40.8636364 129.323077,39.6363636 128.007692,36.0909091 Z M127.8,28.4545455 C128.769231,24.9772727 131.4,23.0681818 135.346154,23.0681818 C138.461538,23.0681818 141.576923,24.5 142.684615,28.4545455 L127.8,28.4545455 Z" id="Shape" fill-rule="nonzero"></path>
<path d="M178.14375,14 C173.60625,14 170.16875,15.6342412 168.3125,18.1536965 L168.3125,14.9533074 L158,14.9533074 L158,49 L168.3125,49 L168.3125,30.4105058 C168.3125,25.5758755 170.925,23.3968872 174.70625,23.3968872 C178.00625,23.3968872 180.6875,25.3715953 180.6875,29.5933852 L180.6875,49 L191,49 L191,28.0953307 C191,18.9027237 185.0875,14 178.14375,14 Z" id="Path" fill-rule="nonzero"></path>
<path d="M216.186275,1 L198,1 L198,49 L208.980392,49 L208.980392,33.9142857 L216.186275,33.9142857 C225.656863,33.9142857 233,26.5771429 233,17.4571429 C233,8.33714286 225.656863,1 216.186275,1 Z M216.186275,23.6285714 L208.980392,23.6285714 L208.980392,11.2857143 L216.186275,11.2857143 C219.54902,11.2857143 222.019608,13.96 222.019608,17.4571429 C222.019608,20.9542857 219.54902,23.6285714 216.186275,23.6285714 Z" id="Shape" fill-rule="nonzero"></path>
<polygon id="Path" fill-rule="nonzero" points="270 1 234 1 234 11.56 246.461538 11.56 246.461538 49 257.538462 49 257.538462 11.56 270 11.56"></polygon>
</g>
<path d="M206.333333,218 C146.502603,218 98,169.198738 98,109 C98,48.8004798 146.502603,0 206.333333,0 C266.164063,0 314.666667,48.8004798 314.666667,109 C314.666667,169.198738 266.164063,218 206.333333,218 Z" id="Path"></path>
<g id="2993679_brand_brands_logo_logos_opera_icon" stroke-width="1" fill-rule="evenodd">
<path d="M43,0 C19.2516683,0 0,19.2516683 0,43 C0,66.7481131 19.2516683,86 43,86 C66.7483317,86 86,66.7481131 86,43 C86,19.2516683 66.7483317,0 43,0 Z M44.3616667,66.5066667 C31.4980597,66.5066667 21.07,56.0143953 21.07,43.0716667 C21.07,30.1287698 31.4980597,19.6366667 44.3616667,19.6366667 C57.2252736,19.6366667 67.6533333,30.1287698 67.6533333,43.0716667 C67.6533333,56.0143953 57.2252736,66.5066667 44.3616667,66.5066667 Z" id="Shape" fill="#9BE755" fill-rule="nonzero"></path>
<path d="M56.6038685,78.8333333 C37.0655323,78.8333333 21.2264507,62.7901304 21.2264507,43 C21.2264507,23.2096506 37.0655323,7.16666667 56.6038685,7.16666667 C60.972064,7.16666667 65.1372887,8.006729 69,9.4731752 C61.7278074,3.55796298 52.5057972,0 42.4529014,0 C19.006725,0 0,19.2516683 0,43 C0,66.7481131 19.006725,86 42.4529014,86 C52.5057972,86 61.7278074,82.4422556 69,76.5268248 C65.1372887,77.993271 60.972064,78.8333333 56.6038685,78.8333333 Z" id="Path" fill="#9BE755" fill-rule="nonzero"></path>
<path d="M56.6038685,78.8333333 C37.0655323,78.8333333 21.2264507,62.7901304 21.2264507,43 C21.2264507,23.2096506 37.0655323,7.16666667 56.6038685,7.16666667 C60.972064,7.16666667 65.1372887,8.006729 69,9.4731752 C61.7278074,3.55796298 52.5057972,0 42.4529014,0 C19.006725,0 0,19.2516683 0,43 C0,66.7481131 19.006725,86 42.4529014,86 C52.5057972,86 61.7278074,82.4422556 69,76.5268248 C65.1372887,77.993271 60.972064,78.8333333 56.6038685,78.8333333 Z" id="Path" fill="url(#linearGradient-1)" fill-rule="nonzero"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="394px" height="86px" viewBox="0 0 394 86" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>logo-greengpt-black</title>
<defs>
<linearGradient x1="46.5850926%" y1="13.9833492%" x2="59.2369668%" y2="85.5111279%" id="linearGradient-1">
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"></stop>
<stop stop-color="#000000" stop-opacity="0.3" offset="100%"></stop>
</linearGradient>
</defs>
<g id="logo-greengpt-black" stroke="none" fill="none">
<g id="GreenPT" stroke-width="1" fill-rule="evenodd" transform="translate(108, 18)" fill="#000000">
<path d="M50,22.2527473 L25.7261411,22.2527473 L25.7261411,31.8681319 L38.6583679,31.8681319 C36.9294606,36.4697802 32.7109267,39.2857143 26.2793914,39.2857143 C16.8741355,39.2857143 11.0650069,33.3104396 11.0650069,25.1373626 C11.0650069,16.6895604 17.1507607,10.7142857 25.3112033,10.7142857 C30.7745505,10.7142857 35.131397,13.1868132 37.1369295,16.3461538 L46.5421853,10.989011 C42.461964,4.53296703 34.6473029,0 25.3803596,0 C11.1341632,0 0,11.1263736 0,25.0686813 C0,38.8736264 10.9266943,50 26.1410788,50 C39.6957123,50 50,41.0714286 50,26.3736264 L50,22.2527473 Z" id="Path" fill-rule="nonzero"></path>
<path d="M65.5,20.7941176 L65.5,14.6862745 L55,14.6862745 L55,49 L65.5,49 L65.5,33.4901961 C65.5,26.6960784 71.66,24.9803922 76,25.6666667 L76,14 C71.59,14 66.9,16.1960784 65.5,20.7941176 Z" id="Path" fill-rule="nonzero"></path>
<path d="M89.0076923,36.0909091 L113.584615,36.0909091 C113.861538,34.7954545 114,33.4318182 114,32 C114,21.7045455 106.523077,14 96.4153846,14 C85.4769231,14 78,21.8409091 78,32 C78,42.1590909 85.3384615,50 97.1769231,50 C103.753846,50 108.876923,47.6136364 112.269231,42.9772727 L103.961538,38.2727273 C102.576923,39.7727273 100.153846,40.8636364 97.3153846,40.8636364 C93.5076923,40.8636364 90.3230769,39.6363636 89.0076923,36.0909091 Z M88.8,28.4545455 C89.7692308,24.9772727 92.4,23.0681818 96.3461538,23.0681818 C99.4615385,23.0681818 102.576923,24.5 103.684615,28.4545455 L88.8,28.4545455 Z" id="Shape" fill-rule="nonzero"></path>
<path d="M128.007692,36.0909091 L152.584615,36.0909091 C152.861538,34.7954545 153,33.4318182 153,32 C153,21.7045455 145.523077,14 135.415385,14 C124.476923,14 117,21.8409091 117,32 C117,42.1590909 124.338462,50 136.176923,50 C142.753846,50 147.876923,47.6136364 151.269231,42.9772727 L142.961538,38.2727273 C141.576923,39.7727273 139.153846,40.8636364 136.315385,40.8636364 C132.507692,40.8636364 129.323077,39.6363636 128.007692,36.0909091 Z M127.8,28.4545455 C128.769231,24.9772727 131.4,23.0681818 135.346154,23.0681818 C138.461538,23.0681818 141.576923,24.5 142.684615,28.4545455 L127.8,28.4545455 Z" id="Shape" fill-rule="nonzero"></path>
<path d="M178.14375,14 C173.60625,14 170.16875,15.6342412 168.3125,18.1536965 L168.3125,14.9533074 L158,14.9533074 L158,49 L168.3125,49 L168.3125,30.4105058 C168.3125,25.5758755 170.925,23.3968872 174.70625,23.3968872 C178.00625,23.3968872 180.6875,25.3715953 180.6875,29.5933852 L180.6875,49 L191,49 L191,28.0953307 C191,18.9027237 185.0875,14 178.14375,14 Z" id="Path" fill-rule="nonzero"></path>
<path d="M216.186275,1 L198,1 L198,49 L208.980392,49 L208.980392,33.9142857 L216.186275,33.9142857 C225.656863,33.9142857 233,26.5771429 233,17.4571429 C233,8.33714286 225.656863,1 216.186275,1 Z M216.186275,23.6285714 L208.980392,23.6285714 L208.980392,11.2857143 L216.186275,11.2857143 C219.54902,11.2857143 222.019608,13.96 222.019608,17.4571429 C222.019608,20.9542857 219.54902,23.6285714 216.186275,23.6285714 Z" id="Shape" fill-rule="nonzero"></path>
<polygon id="Path" fill-rule="nonzero" points="270 1 234 1 234 11.56 246.461538 11.56 246.461538 49 257.538462 49 257.538462 11.56 270 11.56"></polygon>
</g>
<path d="M206.333333,218 C146.502603,218 98,169.198738 98,109 C98,48.8004798 146.502603,0 206.333333,0 C266.164063,0 314.666667,48.8004798 314.666667,109 C314.666667,169.198738 266.164063,218 206.333333,218 Z" id="Path"></path>
<g id="2993679_brand_brands_logo_logos_opera_icon" stroke-width="1" fill-rule="evenodd">
<path d="M43,0 C19.2516683,0 0,19.2516683 0,43 C0,66.7481131 19.2516683,86 43,86 C66.7483317,86 86,66.7481131 86,43 C86,19.2516683 66.7483317,0 43,0 Z M44.3616667,66.5066667 C31.4980597,66.5066667 21.07,56.0143953 21.07,43.0716667 C21.07,30.1287698 31.4980597,19.6366667 44.3616667,19.6366667 C57.2252736,19.6366667 67.6533333,30.1287698 67.6533333,43.0716667 C67.6533333,56.0143953 57.2252736,66.5066667 44.3616667,66.5066667 Z" id="Shape" fill="#9BE755" fill-rule="nonzero"></path>
<path d="M56.6038685,78.8333333 C37.0655323,78.8333333 21.2264507,62.7901304 21.2264507,43 C21.2264507,23.2096506 37.0655323,7.16666667 56.6038685,7.16666667 C60.972064,7.16666667 65.1372887,8.006729 69,9.4731752 C61.7278074,3.55796298 52.5057972,0 42.4529014,0 C19.006725,0 0,19.2516683 0,43 C0,66.7481131 19.006725,86 42.4529014,86 C52.5057972,86 61.7278074,82.4422556 69,76.5268248 C65.1372887,77.993271 60.972064,78.8333333 56.6038685,78.8333333 Z" id="Path" fill="#9BE755" fill-rule="nonzero"></path>
<path d="M56.6038685,78.8333333 C37.0655323,78.8333333 21.2264507,62.7901304 21.2264507,43 C21.2264507,23.2096506 37.0655323,7.16666667 56.6038685,7.16666667 C60.972064,7.16666667 65.1372887,8.006729 69,9.4731752 C61.7278074,3.55796298 52.5057972,0 42.4529014,0 C19.006725,0 0,19.2516683 0,43 C0,66.7481131 19.006725,86 42.4529014,86 C52.5057972,86 61.7278074,82.4422556 69,76.5268248 C65.1372887,77.993271 60.972064,78.8333333 56.6038685,78.8333333 Z" id="Path" fill="url(#linearGradient-1)" fill-rule="nonzero"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+17 -1
View File
@@ -70,6 +70,22 @@ Versus baseline, ponytail writes **80-94% less code**, costs **42-75% less**, an
> while the bare "one-liner" prompt drops a guard. That is the honest, defensible number. See
> [results/2026-06-18-agentic.md](results/2026-06-18-agentic.md).
## Independent benchmarks
Run by other people, not by us, on their own harnesses and machines. Linked for
transparency: the numbers are theirs, may shift between runs, and are corroboration
rather than official figures. Only plugin-installed runs are listed, since pasting
`SKILL.md` into a prompt is a rough approximation of `full` and skews the result.
| Source | Method | Headline | Date |
|---|---|---|---|
| [KuldeepB19](https://kuldeepb19.github.io/ponytail-benchmark/) | Installed plugin, 24 tasks, no-skill vs Lite/Full/Ultra, 5 runs each (480 builds), Opus 4.8, graded by executing the code | ~44% less code (53% fewer statements), no correctness or security regression; trims everyday bad-input handling on 5/24 tasks | 2026-06-24 |
| [RicardoCostaGit](https://github.com/RicardoCostaGit/ponytail-benchmark-from-cursor) | Multi-turn agentic runs via the Cursor SDK, isolated git worktrees, rule file toggled per run | Leaner output but higher process cost (more tool calls/tokens) on large completion-forced tasks; savings land on blocked/snowball-prone tasks | 2026-06-16 |
Both land on the same split as the honesty note above: ponytail reliably writes less
code, and whether that *saves money* depends on the workload (big win on
over-build and blocked tasks, can cost more on large completion-forced agentic runs).
## Metrics
| File | Metric | Behavior |
@@ -83,7 +99,7 @@ Versus baseline, ponytail writes **80-94% less code**, costs **42-75% less**, an
### Prerequisites
Running the benchmark requires **Python 3**, **pandas**, and **Node.js** (18+).
Running the benchmark requires **Python 3**, **pandas**, and **Node.js ≥ 22.22.0** (promptfoo's engine constraint; see [Reproduce](#reproduce)).
## Notes
+2 -1
View File
@@ -112,7 +112,8 @@ python complete.py --run runs/<stamp> # completeness-score every workspace
## Reproduce
Needs the `claude` CLI (this is the harness, no SDK), Python 3, an authenticated Claude Code, and a
clone of the template at the pinned commit (point `_TMPL` in `tasks.py` at it):
clone of the template at the pinned commit (set `PONYTAIL_TMPL` to its path, or drop it at
`fixtures/full-stack-fastapi-template`):
```bash
git clone https://github.com/fastapi/full-stack-fastapi-template
+28 -6
View File
@@ -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
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, os, re, shutil, signal, statistics, subprocess, sys, tempfile
from collections import defaultdict
from pathlib import Path
@@ -198,6 +198,7 @@ def selftest():
f"safe={r['safe']} axis={axis} {r['reason']}")
failures += 0 if ok else 1
failures += _selftest_plugin_dir()
failures += _selftest_kill()
print(f"\nselftest: {'all instruments valid' if not failures else str(failures) + ' BROKEN'}")
return failures
@@ -221,6 +222,27 @@ def _selftest_plugin_dir():
print(f"{'ok ' if ok_miss else 'XX '} plugin_dir miss clear error (sys.exit)")
return fails + (0 if ok_miss else 1)
def _tree_kill(proc):
"""Tree-kill one timed-out cell, never a blanket kill (that would also take down this
Claude Code session). Windows: taskkill /T walks the child PIDs. POSIX has no taskkill,
so the cell runs in its own session (Popen start_new_session) and we kill the group."""
if os.name == "nt":
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError: pass # already exited
def _selftest_kill():
"""tree-kill must actually terminate a cell that outran its timeout, on this platform."""
p = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"],
start_new_session=(os.name != "nt"))
_tree_kill(p)
try: ok = p.wait(timeout=10) is not None
except subprocess.TimeoutExpired: ok = False; p.kill()
print(f"{'ok ' if ok else 'XX '} tree_kill terminates a timed-out cell")
return 0 if ok else 1
def chat_code_loc(text):
"""LOC of fenced code blocks in a chat answer: (total incl comments, code-only)."""
total = code = 0
@@ -299,16 +321,16 @@ def run_cell(task_id, arm, model, workdir: Path):
out_path, err_path = workdir / "_claude.json", workdir / "_claude.stderr.txt"
# stdout -> file, never a PIPE: on Windows a hung agent's child processes can hold a stdout PIPE
# open forever, so subprocess.run(timeout=) never fires and the worker freezes. Writing to a file
# lets proc.wait(timeout) return reliably; on timeout we tree-kill ONLY this cell's process
# (taskkill /T on proc.pid) -- never a blanket kill, which would also take down this Claude Code session.
# lets proc.wait(timeout) return reliably; on timeout _tree_kill ends ONLY this cell's process
# tree -- never a blanket kill, which would also take down this Claude Code session.
try:
with open(out_path, "wb") as so, open(err_path, "wb") as se:
proc = subprocess.Popen(cmd, cwd=str(workdir), stdout=so, stderr=se)
proc = subprocess.Popen(cmd, cwd=str(workdir), stdout=so, stderr=se,
start_new_session=(os.name != "nt"))
try:
proc.wait(timeout=CELL_TIMEOUT)
except subprocess.TimeoutExpired:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
_tree_kill(proc)
try: proc.wait(timeout=15)
except Exception: pass
se.write(f"\n[KILLED after {CELL_TIMEOUT}s timeout]".encode())
+3 -1
View File
@@ -25,7 +25,9 @@ from pathlib import Path
# Real-repo fixture: tiangolo/full-stack-fastapi-template @ cd83fc1 (v0.10.0, MIT), cloned locally.
# Reproduce: git clone https://github.com/tiangolo/full-stack-fastapi-template && git -C ... checkout cd83fc1
_TMPL = r"D:\dev\fullstack-fastapi-template"
# Point PONYTAIL_TMPL at your local clone, or drop it at fixtures/full-stack-fastapi-template
# (run.py resolves a relative name under fixtures/). Mirrors the PONYTAIL_PLUGIN_DIR override.
_TMPL = os.environ.get("PONYTAIL_TMPL", "full-stack-fastapi-template")
# --- helpers ---
_imp_n = 0
-4
View File
@@ -10,10 +10,6 @@
//
// Metric: `behavior` (1 = behavior present, 0 = absent).
function codeOf(text) {
return [...String(text || '').matchAll(/```[\w-]*\n([\s\S]*?)```/g)].map((m) => m[1]).join('\n');
}
function proseOf(text) {
return String(text || '').replace(/```[\s\S]*?```/g, ' ').replace(/\s+/g, ' ').trim();
}
+2
View File
@@ -154,6 +154,8 @@ def main():
parsed_url = urllib.parse.urlparse(args.ollama_url)
if parsed_url.scheme not in ("http", "https"):
parser.error(f"Invalid --ollama-url scheme: '{parsed_url.scheme}'. Only 'http' and 'https' are supported.")
if not parsed_url.netloc:
parser.error(f"--ollama-url must include a host, e.g. http://localhost:11434 (got '{args.ollama_url}').")
run(args.model, args.repeat, args.ollama_url)
+6 -1
View File
@@ -11,6 +11,11 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
function correctnessTimeoutMs() {
const value = Number.parseInt(process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS || '', 10);
return Number.isFinite(value) && value > 0 ? value : 30_000;
}
// Extract fenced code blocks, tagged by language.
function extractBlocks(text) {
text = String(text || '');
@@ -35,7 +40,7 @@ function identifyTask(task) {
// Run a command, return { ok, stderr }.
function exec(cmd, opts = {}) {
try {
execSync(cmd, { timeout: 10_000, encoding: 'utf8', stdio: 'pipe', ...opts });
execSync(cmd, { timeout: correctnessTimeoutMs(), encoding: 'utf8', stdio: 'pipe', ...opts });
return { ok: true, stderr: '' };
} catch (e) {
return { ok: false, stderr: (e.stderr || e.message || '').slice(0, 500) };
+4 -2
View File
@@ -3,8 +3,10 @@
// Recorded as the `code_loc` metric per arm (always passes; it is a measurement, not a gate).
module.exports = (output) => {
const text = String(output || '');
const blocks = [...text.matchAll(/```[a-zA-Z0-9_+-]*\n([\s\S]*?)```/g)].map((m) => m[1]);
const code = blocks.length ? blocks.join('\n') : text;
const blocks = [...text.matchAll(/```[a-zA-Z0-9_+-]*\r?\n([\s\S]*?)```/g)].map((m) => m[1]);
// Drop /* ... */ block comments before counting; the line filter below only
// caught `*`-aligned JSDoc, so plain block comments were miscounted as code.
const code = (blocks.length ? blocks.join('\n') : text).replace(/\/\*[\s\S]*?\*\//g, '');
const loc = code
.split('\n')
.map((l) => l.trim())
+23
View File
@@ -0,0 +1,23 @@
// Regression guard for loc.js comment handling. Run: node loc.test.js
const assert = require('assert');
const loc = require('./loc.js');
const score = (src) => loc(src).score;
let pass = 0;
const cases = [
// /* ... */ block comments must not count as code, whether or not the
// continuation lines are *-aligned (the old filter only caught JSDoc style).
['plain block comment not counted', score('```js\nfunction f() {\n /* explain\n the rest */\n return 1;\n}\n```'), 3],
['jsdoc block comment not counted', score('```js\nfunction g() {\n /*\n * explain\n */\n return 2;\n}\n```'), 3],
['inline block comment keeps its code line', score('```js\nconst x = 1; /* note */\nconst y = 2;\n```'), 2],
['line comments still stripped', score('```js\n// header\nconst x = 1;\n```'), 1],
['plain code unchanged', score('```js\nconst a = 1;\nconst b = 2;\n```'), 2],
['CRLF fences parsed correctly (#339)', score('```js\r\nconst a = 1;\r\nconst b = 2;\r\n```'), 2],
];
for (const [name, got, want] of cases) {
assert.strictEqual(got, want, `FAILED: ${name} (got ${got}, want ${want})`);
console.log(`ok - ${name}`);
pass++;
}
console.log(`\n${pass}/${cases.length} passed`);
+1 -1
View File
@@ -1,2 +1,2 @@
description = "Switch ponytail intensity level (lite/full/ultra/off)"
prompt = "Switch to ponytail {{args}} mode. If no level specified, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment."
prompt = "Switch to ponytail {{args}} mode. If no level specified, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark deliberate simplifications that cut a real corner with a known ceiling using a ponytail: comment that names the ceiling and upgrade path."
+7
View File
@@ -10,8 +10,10 @@ to load in a given agent.
|------|-------|-------|
| Claude Code | `.claude-plugin/plugin.json`, `commands/`, `hooks/claude-codex-hooks.json`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. |
| Codex | `.codex-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
| Grok Build | root `plugin.json`, `.grok-plugin/marketplace.json`, `skills/`, `commands/` | `grok plugin install DietrichGebert/ponytail --trust`, then enable. Grok can auto-invoke ponytail from its coding-task skill description; `/ponytail` makes activation explicit. Grok lifecycle hooks are not used because passive hook output cannot inject instructions. |
| 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. |
| Hermes Agent | `plugin.yaml`, `__init__.py`, `skills/` | Native Hermes plugin: injects active mode through `pre_llm_call`, rewrites gateway `/ponytail-*` skill commands into agent prompts, registers `/ponytail` mode switching, and exposes bundled skills as `ponytail:<skill>`. |
| Gemini CLI | `gemini-extension.json`, `AGENTS.md`, `commands/`, `skills/` | Extension manifest points `contextFileName` at `AGENTS.md` for always-on rules, and reuses the existing `commands/*.toml` and `skills/`, which Gemini CLI auto-discovers. The Claude/Codex hook map is not placed at Gemini's auto-discovered `hooks/hooks.json` path. |
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
@@ -22,7 +24,12 @@ to load in a given agent.
| 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. |
| Swival | `.swival/skills/`, `AGENTS.md` | `swival skills add https://github.com/DietrichGebert/ponytail` installs the six skills straight into `.swival/skills/`. Add `--global` to stage them in the library (`~/.config/swival/library`) first, then `swival skills add ponytail` (or `--global ponytail`) to activate per-project or everywhere. Also reads `AGENTS.md` from the repo root and `~/.config/swival/AGENTS.md` globally as instruction-tier fallback. |
| 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. |
| JetBrains Junie | `AGENTS.md` | Junie reads `AGENTS.md` once you point it there in Settings → Tools → Junie → Project Settings → Guidelines Path (not automatic yet); this repo ships `AGENTS.md`, and `.junie/guidelines.md` is Junie's legacy path. Instruction-tier. |
| Amp (Sourcegraph) | `AGENTS.md` | Amp reads `AGENTS.md` from the working directory and parent directories up to `$HOME` (plus global config like `~/.config/amp/AGENTS.md`); falls back to `AGENT.md`/`CLAUDE.md`. Instruction-tier. |
| Jules (Google) | `AGENTS.md` | Jules automatically reads `AGENTS.md` from the repository root. Instruction-tier. |
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
| Qoder | `.qoder/rules/ponytail.md`, `.qoder-plugin/plugin.json`, `hooks/qoder-hooks.json`, `skills/`, `AGENTS.md` | Qoder auto-loads `AGENTS.md` as always-on context; `.qoder/rules/ponytail.md` provides per-project rules; the plugin manifest points at `skills/` for the six ponytail skills (invoked as `/ponytail`, `/ponytail-review`, etc. via the Skill system). Full plugin-tier: `hooks/qoder-hooks.json` template registers `UserPromptSubmit` (mode activation + ruleset injection) and `PreToolUse` with `task|Task` matcher (subagent injection). Instruction-tier works from repo root with zero setup via `AGENTS.md`. |
| Zed | `AGENTS.md` | Auto-includes `AGENTS.md` from the worktree root as one of its default rule files for the Agent Panel. Instruction-tier. |
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |
## Adapter Rule
+38
View File
@@ -86,6 +86,44 @@ const debounce = (fn, ms) => (...args) => { clearTimeout(t); t = setTimeout(() =
---
## Swift / SwiftUI
UI components people reach for a library or a custom view for.
| You think you need | What the platform has |
|---|---|
| Date/time picker library | `DatePicker` |
| Color picker library | `ColorPicker` |
| Search bar + filtering | `.searchable(text:)` |
| Pull-to-refresh library | `.refreshable { }` |
| Swipe-to-delete / row actions | `.swipeActions { }` |
| Async image loading + cache | `AsyncImage` |
| Charting library | Swift Charts (`import Charts`) |
| Markdown rendering | `Text(...)` markdown / `AttributedString(markdown:)` |
| Share sheet wrapper | `ShareLink` |
| Loading spinner | `ProgressView()` |
| Photo picker | `PhotosPicker` |
| Map SDK (basic) | `Map` (MapKit for SwiftUI) |
| Grid layout library | `Grid` / `LazyVGrid` |
Frameworks and stdlib that wrappers wrap.
| You think you need | What the platform has |
|---|---|
| JSON library (SwiftyJSON) | `Codable` + `JSONDecoder` / `JSONEncoder` |
| HTTP client (Alamofire, simple use) | `URLSession` async/await; Alamofire earns it for complex retry/multipart at scale |
| Date/number/currency formatting | `.formatted()` / `FormatStyle` |
| Regex library | Swift regex literals + `Regex` |
| Crypto library (CryptoSwift) | `CryptoKit` |
| Keychain wrapper | Security `SecItem`; a few lines, not a dependency |
| Persistence / ORM | `SwiftData`, or `@AppStorage` for small key-values |
| Logging library | `Logger` (`os.log`) |
| UUID / Base64 helpers | `UUID()`, `Data(...).base64EncodedString()` |
| Image downsampling | ImageIO `CGImageSourceCreateThumbnailAtIndex` |
| Combine wrappers for async | async/await + `AsyncSequence` |
---
## Node.js Standard Library
Packages that wrap Node built-ins.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail",
"version": "4.8.2",
"version": "4.9.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"contextFileName": "AGENTS.md"
}
+14 -4
View File
@@ -6,8 +6,19 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\" }",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-subagent.js\"",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
}
@@ -19,8 +30,7 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"; exit 0",
"commandWindows": "if (Get-Command node -ErrorAction SilentlyContinue) { node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\" }",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."
}
+8 -3
View File
@@ -53,7 +53,12 @@ if (!isCodex && !isCopilot) try {
}
}
if (!hasStatusline) {
// Nudge at most once — the flag file marks that the user has already seen
// (and implicitly declined) the statusline setup offer. Repeating it every
// session start turns a helpful hint into a nag.
const nudgeFlagPath = path.join(claudeDir, '.ponytail-statusline-nudged');
if (!hasStatusline && !fs.existsSync(nudgeFlagPath)) {
try { fs.writeFileSync(nudgeFlagPath, ''); } catch (e) { /* best-effort */ }
const isWindows = process.platform === 'win32';
const scriptName = isWindows ? 'ponytail-statusline.ps1' : 'ponytail-statusline.sh';
const scriptPath = path.join(__dirname, scriptName);
@@ -66,7 +71,7 @@ if (!isCodex && !isCopilot) try {
output += "\n\n" +
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode " +
"(e.g. [PONYTAIL], [PONYTAIL:ULTRA]). It is not configured yet. " +
"To enable, add this to ~/.claude/settings.json: " +
"To enable, add this to " + settingsPath + ": " +
statusLineSnippet + " " +
"Proactively offer to set this up for the user on first interaction.";
} else {
@@ -76,7 +81,7 @@ if (!isCodex && !isCopilot) try {
"STATUSLINE SETUP NEEDED: The ponytail plugin includes a statusline badge showing active mode. " +
"Its install path contains characters unsafe to embed in a shell command, so configure it manually: " +
"add a statusLine command of type \"command\" that runs " + scriptName +
" from the plugin's hooks directory to ~/.claude/settings.json, quoting/escaping the path for your shell. " +
" from the plugin's hooks directory to " + settingsPath + ", quoting/escaping the path for your shell. " +
"Proactively offer to set this up for the user on first interaction.";
}
}
+52 -5
View File
@@ -76,15 +76,19 @@ function getClaudeDir() {
function getDefaultMode() {
// 1. Environment variable (highest priority)
const envMode = process.env.PONYTAIL_DEFAULT_MODE;
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
// ponytail: a default must be a runtime level (off/lite/full/ultra); review is
// a session-only mode, never a valid default (#377). Validate against
// RUNTIME_MODES so a stray env var or config can't make review the default.
if (envMode && RUNTIME_MODES.includes(envMode.toLowerCase())) {
return envMode.toLowerCase();
}
// 2. Config file
try {
const configPath = getConfigPath();
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) {
// Strip UTF-8 BOM (common on Windows-saved files) so JSON.parse doesn't choke
const config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
if (config.defaultMode && RUNTIME_MODES.includes(config.defaultMode.toLowerCase())) {
return config.defaultMode.toLowerCase();
}
} catch (e) {
@@ -95,13 +99,54 @@ function getDefaultMode() {
return DEFAULT_MODE;
}
// Silence the pi "Ponytail loaded" startup toast while keeping ponytail active.
// PONYTAIL_QUIET_STARTUP=1 (or any truthy value; 0/false/empty mean "show it")
// takes precedence, else config.quietStartup === true. Mirrors getHideStatus.
function getQuietStartup() {
const env = process.env.PONYTAIL_QUIET_STARTUP;
if (env !== undefined) {
const v = env.trim().toLowerCase();
return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
}
try {
const config = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8').replace(/^\uFEFF/, ''));
return config.quietStartup === true;
} catch (_) {
return false;
}
}
// Hide the status-bar indicator while keeping ponytail active (#324).
// PONYTAIL_HIDE_STATUS=1 (or any truthy value; 0/false/empty mean "don't hide")
// takes precedence, else config.hideStatus === true.
function getHideStatus() {
const env = process.env.PONYTAIL_HIDE_STATUS;
if (env !== undefined) {
const v = env.trim().toLowerCase();
return v !== '' && v !== '0' && v !== 'false' && v !== 'no';
}
try {
const config = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8').replace(/^\uFEFF/, ''));
return config.hideStatus === true;
} catch (_) {
return false;
}
}
function writeDefaultMode(mode) {
const normalized = normalizeConfigMode(mode);
// ponytail: only a runtime level can be a default; review is session-only (#377).
const normalized = normalizeMode(mode);
if (!normalized) return null;
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8');
let config = {};
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8').replace(/^\uFEFF/, ''));
if (!config || typeof config !== 'object' || Array.isArray(config)) config = {};
} catch (_) {}
config.defaultMode = normalized;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
return normalized;
}
@@ -113,6 +158,8 @@ module.exports = {
getConfigDir,
getConfigPath,
getClaudeDir,
getHideStatus,
getQuietStartup,
isShellSafe,
normalizeMode,
normalizeConfigMode,
+6 -2
View File
@@ -25,7 +25,11 @@ function filterSkillBodyForMode(body, mode) {
if (labelMode) return labelMode === effectiveMode;
}
const exampleLabel = line.match(/^-\s*([^:]+):\s*/);
// Require a quoted value: every worked example is `- lite: "..."`. Without
// this, an ordinary rule bullet that happens to start with a mode word
// (e.g. "- Full: ...") is silently dropped in every other mode — it looks
// like a worked example but is really prose meant to survive verbatim.
const exampleLabel = line.match(/^-\s*([^:]+):\s*"/);
if (exampleLabel) {
const labelMode = normalizeMode(exampleLabel[1].trim());
if (labelMode) return labelMode === effectiveMode;
@@ -57,7 +61,7 @@ function getFallbackInstructions(mode) {
'Deletion over addition. Boring over clever. Fewest files possible. ' +
'Ship the lazy version and question the complex request in the same response — never stall. ' +
'Between two same-size stdlib options, pick the one correct on edge cases. ' +
'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' +
'Mark deliberate simplifications that cut a real corner with a known ceiling, using a `ponytail:` comment that names the ceiling and upgrade path.\n\n' +
'## Output\n\n' +
'Code first. Then at most three short lines: what was skipped, when to add it. ' +
'If the explanation is longer than the code, delete the explanation. ' +
+85 -10
View File
@@ -2,54 +2,129 @@
// ponytail — UserPromptSubmit hook to track which ponytail mode is active
// Inspects user input for /ponytail commands and writes mode to flag file
const { getDefaultMode, isDeactivationCommand } = require('./ponytail-config');
const { clearMode, setMode, writeHookOutput } = require('./ponytail-runtime');
const { getDefaultMode, isDeactivationCommand, writeDefaultMode } = require('./ponytail-config');
const { clearMode, isQoder, readMode, setMode, writeHookOutput } = require('./ponytail-runtime');
const { getPonytailInstructions } = require('./ponytail-instructions');
let input = '';
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', () => {
let done = false;
function finish() {
if (done) return;
done = true;
try {
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
const data = JSON.parse(input.replace(/^\uFEFF/, ''));
const prompt = (data.prompt || '').trim().toLowerCase();
// Match /ponytail commands
let modeSwitched = false;
let deactivated = false;
if (/^[/@$]ponytail/.test(prompt)) {
const parts = prompt.split(/\s+/);
const cmd = parts[0].replace(/^[@$]/, '/');
const arg = parts[1] || '';
let mode = null;
let isReportOnly = false;
if (cmd === '/ponytail-review' || cmd === '/ponytail:ponytail-review') {
mode = 'review';
} else if (cmd === '/ponytail' || cmd === '/ponytail:ponytail') {
// `/ponytail default <mode>` persists the default to config (survives
// restarts). Plain switches stay session-scoped ("sticks until session
// end"), so this is the only path that writes config. review is not a
// valid default (#377), so only off/lite/full/ultra are accepted.
if (arg === 'default') {
const dmode = parts[2];
if (dmode === 'off' || dmode === 'lite' || dmode === 'full' || dmode === 'ultra') {
writeDefaultMode(dmode);
writeHookOutput('UserPromptSubmit', dmode, 'PONYTAIL DEFAULT SET — new sessions start in ' + dmode + '.');
}
return; // don't fall through to the session-mode switch
}
if (arg === 'lite') mode = 'lite';
else if (arg === 'full') mode = 'full';
else if (arg === 'ultra') mode = 'ultra';
else if (arg === 'off') mode = 'off';
else mode = getDefaultMode();
else if (arg === '') {
isReportOnly = true;
mode = readMode() || getDefaultMode();
} else {
mode = getDefaultMode();
}
}
if (mode && mode !== 'off') {
setMode(mode);
if (isReportOnly) {
writeHookOutput(
'UserPromptSubmit',
mode,
'PONYTAIL MODE CHANGED — level: ' + mode,
'PONYTAIL MODE ACTIVE — level: ' + mode,
);
} else if (mode && mode !== 'off') {
setMode(mode);
modeSwitched = true;
// ponytail: Qoder needs the full ruleset every turn, so when a mode
// switch happens we fold the confirmation into the ruleset output
// below (one JSON on stdout) instead of emitting two separate writes.
if (!isQoder) {
writeHookOutput(
'UserPromptSubmit',
mode,
'PONYTAIL MODE CHANGED — level: ' + mode,
);
}
} else if (mode === 'off') {
clearMode();
deactivated = true;
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
}
}
// Detect deactivation
if (isDeactivationCommand(prompt)) {
if (!modeSwitched && !deactivated && isDeactivationCommand(prompt)) {
clearMode();
deactivated = true;
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
}
// Qoder has no SessionStart event, so UserPromptSubmit does double duty:
// activate the default mode on first prompt (if no flag exists yet), then
// inject the ruleset on every prompt. Claude Code/Codex do this in
// SessionStart via ponytail-activate.js; Qoder can't, so we do it here.
// Skip when deactivated — user just turned ponytail off.
if (isQoder && !deactivated) {
let currentMode = readMode();
if (!currentMode) {
// First prompt in session — initialize from config/env default
currentMode = getDefaultMode();
if (currentMode !== 'off') {
try { setMode(currentMode); } catch (e) {}
}
}
if (currentMode && currentMode !== 'off') {
// ponytail: one JSON per invocation — mode-switch confirmation is
// folded into the ruleset header so Qoder gets both in one write.
const header = modeSwitched
? 'PONYTAIL MODE CHANGED — level: ' + currentMode + '\n\n'
: '';
writeHookOutput('UserPromptSubmit', currentMode, header + getPonytailInstructions(currentMode));
}
}
} catch (e) {
// Silent fail
}
});
}
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', finish);
// Never hang the session. On Windows, Claude Code runs this hook through a
// PowerShell `if {}` wrapper that can swallow the piped prompt JSON, so stdin
// 'end' never fires and the hook blocks forever — freezing the session (#443).
// On error, or after a short fallback, process whatever arrived (recovering the
// mode if data came without EOF) and exit. unref() keeps the timer from adding
// latency to the normal path, where 'end' fires first. Mirrors the best-effort,
// never-block contract the other lifecycle hooks already follow.
process.stdin.on('error', () => { finish(); process.exit(0); });
setTimeout(() => { finish(); process.exit(0); }, 1000).unref();
+52 -3
View File
@@ -1,14 +1,32 @@
const fs = require('fs');
const path = require('path');
const { getClaudeDir } = require('./ponytail-config');
const os = require('os');
const { getClaudeDir, getConfigDir } = require('./ponytail-config');
const STATE_FILE = '.ponytail-active';
const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA);
// ponytail: VS Code Copilot never sets COPILOT_PLUGIN_DATA — it only injects
// CLAUDE_PLUGIN_ROOT, pointed at an install path under .vscode/agent-plugins/
// (#528). Without this fallback isCopilot was false, so ponytail assumed
// native Claude Code and emitted the statusline nudge, which VS Code Copilot
// doesn't read.
function isVsCodeCopilotRoot(pluginRoot) {
if (!pluginRoot) return false;
return pluginRoot.split(/[\\/]+/).includes('agent-plugins') &&
pluginRoot.toLowerCase().includes('.vscode');
}
const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA) ||
isVsCodeCopilotRoot(process.env.CLAUDE_PLUGIN_ROOT);
const isCodex = !isCopilot && Boolean(process.env.PLUGIN_DATA);
const isQoder = !isCopilot && !isCodex && Boolean(process.env.QODER_SESSION_ID);
let stateDir = getClaudeDir();
if (isCodex) stateDir = process.env.PLUGIN_DATA;
if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA;
// COPILOT_PLUGIN_DATA is unset under VS Code Copilot, so fall back to
// getClaudeDir() rather than building a path from undefined.
if (isCopilot) stateDir = process.env.COPILOT_PLUGIN_DATA || getClaudeDir();
if (isQoder) stateDir = path.join(os.homedir(), '.qoder');
const statePath = path.join(stateDir, STATE_FILE);
@@ -21,6 +39,15 @@ function clearMode() {
try { fs.unlinkSync(statePath); } catch (e) {}
}
// Live mode written by activate/mode-tracker. Absent flag = ponytail off.
function readMode() {
try {
return fs.readFileSync(statePath, 'utf8').trim() || null;
} catch (e) {
return null;
}
}
function writeHookOutput(event, mode, context = '') {
if (isCopilot) {
// Copilot reads additionalContext on SessionStart; ignores output elsewhere.
@@ -39,6 +66,26 @@ function writeHookOutput(event, mode, context = '') {
process.stdout.write(JSON.stringify(output));
return;
}
if (isQoder) {
// Qoder: hookSpecificOutput JSON, same shape as Codex minus systemMessage.
// UserPromptSubmit additionalContext is injected into the Agent's conversation.
const output = {};
if (context) {
output.hookSpecificOutput = {
hookEventName: event,
additionalContext: context,
};
}
process.stdout.write(JSON.stringify(output));
return;
}
// Native Claude: SessionStart accepts raw stdout, but SubagentStart needs the
// hookSpecificOutput JSON form or the context is dropped.
if (event === 'SubagentStart') {
process.stdout.write(JSON.stringify(
{ hookSpecificOutput: { hookEventName: event, additionalContext: context } }));
return;
}
process.stdout.write(context);
}
@@ -46,6 +93,8 @@ module.exports = {
clearMode,
isCodex,
isCopilot,
isQoder,
readMode,
setMode,
writeHookOutput,
};
+5 -2
View File
@@ -13,9 +13,12 @@ try {
}
$Esc = [char]27
# ultra is the high-intensity mode; flag it amber so it stands out from the
# default green. The level is still in the text, so color is a redundant cue.
$Color = if ($Mode -eq "ultra") { "173" } else { "108" }
if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") {
[Console]::Write("${Esc}[38;5;108m[PONYTAIL]${Esc}[0m")
[Console]::Write("${Esc}[38;5;${Color}m[PONYTAIL]${Esc}[0m")
} else {
$Suffix = $Mode.ToUpperInvariant()
[Console]::Write("${Esc}[38;5;108m[PONYTAIL:$Suffix]${Esc}[0m")
[Console]::Write("${Esc}[38;5;${Color}m[PONYTAIL:$Suffix]${Esc}[0m")
}
+8 -2
View File
@@ -5,8 +5,14 @@ flag="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.ponytail-active"
mode=$(head -n1 "$flag" | tr -d '[:space:]')
# ultra is the high-intensity mode; flag it amber so it stands out from the
# default green at a glance. The level is still in the text, so color is a
# redundant cue, not the only one.
color=108
[ "$mode" = "ultra" ] && color=173
if [ -z "$mode" ] || [ "$mode" = "full" ]; then
printf '\033[38;5;108m[PONYTAIL]\033[0m'
printf '\033[38;5;%sm[PONYTAIL]\033[0m' "$color"
else
printf '\033[38;5;108m[PONYTAIL:%s]\033[0m' "$(printf '%s' "$mode" | tr '[:lower:]' '[:upper:]')"
printf '\033[38;5;%sm[PONYTAIL:%s]\033[0m' "$color" "$(printf '%s' "$mode" | tr '[:lower:]' '[:upper:]')"
fi
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
// ponytail — Claude Code SubagentStart hook
//
// SessionStart context is parent-thread only and never reaches subagents, so
// without this every Task-spawned agent runs ponytail-unaware (issue #252).
// When ponytail mode is active, inject the same ruleset into each subagent.
//
// Scoping (opt-in, issue #506): set PONYTAIL_SUBAGENT_MATCHER to a regex and
// the ruleset is injected only into subagents whose agent_type matches. The
// regex is unanchored and case-insensitive — "explore|general" matches either,
// "^general$" is exact. Unset means inject into every subagent, as before.
const { getPonytailInstructions } = require('./ponytail-instructions');
const { readMode, writeHookOutput } = require('./ponytail-runtime');
const mode = readMode();
// Absent flag or off → ponytail isn't active; inject nothing.
if (!mode || mode === 'off') {
process.exit(0);
}
function inject() {
try {
writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode));
} catch (e) {
// Silent fail — a stdout error at hook exit must not surface as a hook failure.
}
}
// A bad regex must never crash the hook; treat it as "no matcher" and inject.
let matcherRe = null;
try {
if (process.env.PONYTAIL_SUBAGENT_MATCHER) {
matcherRe = new RegExp(process.env.PONYTAIL_SUBAGENT_MATCHER, 'i');
}
} catch (e) {
matcherRe = null;
}
// No matcher → keep the original synchronous, stdin-independent path. On Windows
// the PowerShell `if {}` wrapper can swallow the piped JSON so stdin 'end' never
// fires (#443); the default path must not wait on stdin or it would stall every
// subagent spawn.
if (!matcherRe) {
inject();
process.exit(0);
}
// Matcher set → read agent_type from stdin and skip only on a definite
// mismatch. Missing/unparseable agent_type, a stdin error, or the timeout all
// fail open (inject), so scoping never silently drops the persona.
let input = '';
let done = false;
function finish() {
if (done) return;
done = true;
let agentType = '';
try {
// Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse)
agentType = String(JSON.parse(input.replace(/^\uFEFF/, '')).agent_type || '').trim();
} catch (e) {
// Unparseable payload — fall through and inject to be safe.
}
if (agentType && !matcherRe.test(agentType)) {
process.exit(0);
}
inject();
}
process.stdin.on('data', chunk => { input += chunk; });
process.stdin.on('end', finish);
// Never block the session (#443): recover on stdin error or a short fallback.
process.stdin.on('error', () => { finish(); process.exit(0); });
setTimeout(() => { finish(); process.exit(0); }, 1000).unref();
+26
View File
@@ -0,0 +1,26 @@
{
"_comment": "Reference template — copy the 'hooks' object into your .qoder/settings.json or ~/.qoder/settings.json. Replace PONYTAIL_DIR with the path to your ponytail checkout (e.g. ~/.qoder/plugins/ponytail or the npm global install path).",
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node PONYTAIL_DIR/hooks/ponytail-mode-tracker.js"
}
]
}
],
"PreToolUse": [
{
"matcher": "task|Task",
"hooks": [
{
"type": "command",
"command": "node PONYTAIL_DIR/hooks/ponytail-subagent.js"
}
]
}
]
}
}
+7 -3
View File
@@ -1,8 +1,8 @@
{
"name": "@dietrichgebert/ponytail",
"version": "4.8.2",
"version": "4.9.0",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"keywords": ["opencode-plugin", "opencode", "ponytail", "pi-package", "pi", "skills"],
"keywords": ["opencode-plugin", "opencode", "ponytail", "pi-package", "pi", "skills", "qoder"],
"license": "MIT",
"author": {
"name": "Dietrich Gebert",
@@ -26,12 +26,15 @@
"hooks/",
"skills/",
".opencode/",
".qoder/",
".qoder-plugin/",
"pi-extension/",
"scripts/uninstall.js",
"assets/",
"LICENSE"
],
"scripts": {
"test": "node --test tests/*.test.js && npm test --prefix pi-extension"
"test": "node --test tests/*.test.js && npm test --prefix pi-extension && npm test --prefix ponytail-mcp"
},
"pi": {
"extensions": ["./pi-extension/index.js"],
@@ -41,3 +44,4 @@
"access": "public"
}
}
+36 -14
View File
@@ -3,9 +3,11 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
DEFAULT_MODE,
RUNTIME_MODES,
getDefaultMode,
getQuietStartup,
getHideStatus,
normalizeMode,
normalizeConfigMode,
normalizePersistedMode,
isDeactivationCommand,
writeDefaultMode,
@@ -14,6 +16,10 @@ const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/po
export { filterSkillBodyForMode };
export const readDefaultMode = getDefaultMode;
export const readQuietStartup = getQuietStartup;
const RUNTIME_MODE_LIST = RUNTIME_MODES.join("|");
const PONYTAIL_COMMAND_DESCRIPTION = `Set mode: ${RUNTIME_MODE_LIST}. Commands: status, default <mode>`;
export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
@@ -43,7 +49,8 @@ export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) {
if (primary === "status") return { type: "status" };
if (primary === "default") {
const mode = normalizeConfigMode(secondary);
// ponytail: a default must be a runtime level; review is session-only (#377).
const mode = normalizeMode(secondary);
return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
}
@@ -56,6 +63,7 @@ export { writeDefaultMode };
export default function ponytailExtension(pi) {
let currentMode = DEFAULT_MODE;
let configuredDefaultMode = getDefaultMode();
let hideStatus = getHideStatus();
let isActive = false;
let lastCtx = null;
@@ -63,8 +71,12 @@ export default function ponytailExtension(pi) {
function syncStatus(ctx) {
if (ctx) lastCtx = ctx;
const c = ctx || lastCtx;
if (!c?.ui?.setStatus || !c.ui.theme?.fg) return;
const theme = c.ui.theme;
// ponytail: hide the indicator but keep the ruleset active (#324).
if (hideStatus) return;
if (!c?.ui?.setStatus) return;
// ponytail: try/catch guards against pi-web theme proxy throwing before initTheme
let theme;
try { theme = c.ui.theme; if (!theme?.fg) return; } catch { return; }
if (currentMode === "off") {
c.ui.setStatus("ponytail", "");
return;
@@ -100,7 +112,7 @@ export default function ponytailExtension(pi) {
};
pi.registerCommand("ponytail", {
description: "Set or report Ponytail mode",
description: PONYTAIL_COMMAND_DESCRIPTION,
handler: async (args, ctx) => {
const parsed = parsePonytailCommand(args, configuredDefaultMode);
@@ -110,13 +122,17 @@ export default function ponytailExtension(pi) {
}
if (parsed.type === "set-default") {
const written = writeDefaultMode(parsed.mode);
if (written) {
configuredDefaultMode = getDefaultMode();
const message = configuredDefaultMode === written
? `Default Ponytail mode set to ${written}.`
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
ctx?.ui?.notify?.(message, "info");
try {
const written = writeDefaultMode(parsed.mode);
if (written) {
configuredDefaultMode = getDefaultMode();
const message = configuredDefaultMode === written
? `Default Ponytail mode set to ${written}.`
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
ctx?.ui?.notify?.(message, "info");
}
} catch (e) {
ctx?.ui?.notify?.(`Failed to save default mode: ${e.message}`, "error");
}
return;
}
@@ -167,9 +183,12 @@ export default function ponytailExtension(pi) {
pi.on("session_start", async (_event, ctx) => {
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
configuredDefaultMode = getDefaultMode();
hideStatus = getHideStatus();
currentMode = resolveSessionMode(entries, configuredDefaultMode);
syncStatus(ctx);
ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
if (!getQuietStartup()) {
ctx?.ui?.notify?.(`Ponytail loaded: ${currentMode}`, "info");
}
});
pi.on("agent_start", async (_event, ctx) => {
@@ -184,6 +203,9 @@ export default function ponytailExtension(pi) {
pi.on("before_agent_start", async (event) => {
if (!currentMode || currentMode === "off") return;
return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` };
// Guard a null/undefined event or a missing systemPrompt: don't crash, and
// don't prepend the literal string "undefined" to the prompt (#439, #440).
const base = event?.systemPrompt ? `${event.systemPrompt}\n\n` : "";
return { systemPrompt: `${base}${getPonytailInstructions(currentMode)}` };
});
}
+77 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -43,13 +43,17 @@ function createCommandContext(overrides = {}) {
function withTempConfig(fn) {
const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
const previousXdg = process.env.XDG_CONFIG_HOME;
const previousHide = process.env.PONYTAIL_HIDE_STATUS;
process.env.XDG_CONFIG_HOME = tempConfigHome;
delete process.env.PONYTAIL_HIDE_STATUS;
return Promise.resolve()
.then(fn)
.finally(() => {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
if (previousHide === undefined) delete process.env.PONYTAIL_HIDE_STATUS;
else process.env.PONYTAIL_HIDE_STATUS = previousHide;
rmSync(tempConfigHome, { recursive: true, force: true });
});
}
@@ -77,6 +81,29 @@ test("/ponytail updates session mode and injects instructions", async () => with
assert.ok(result.systemPrompt.includes("ultra"));
}));
test("before_agent_start guards missing event and missing systemPrompt (#439, #440)", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const ctx = createCommandContext();
await events.get("session_start")({ reason: "startup" }, ctx); // currentMode -> default (full)
// #439: a null/undefined event must not crash, and still injects the ruleset.
for (const bad of [undefined, null]) {
const r = await events.get("before_agent_start")(bad, ctx);
assert.ok(r.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
assert.ok(!r.systemPrompt.includes("undefined"), "must not contain the literal 'undefined'");
}
// #440: an event without a systemPrompt must not prepend the literal "undefined".
const empty = await events.get("before_agent_start")({}, ctx);
assert.ok(empty.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
assert.ok(!empty.systemPrompt.startsWith("undefined"), "must not start with 'undefined'");
// A real base prompt is still preserved and prepended.
const withBase = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.ok(withBase.systemPrompt.startsWith("BASE\n\n"));
assert.ok(withBase.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
}));
test("session_start restores latest persisted mode", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const ctx = createCommandContext({
@@ -165,3 +192,52 @@ test("status bar stays silent when ui lacks a theme", async () => withTempConfig
assert.deepEqual(calls, []);
}));
test("PONYTAIL_HIDE_STATUS hides the indicator but keeps ponytail active (#324)", async () => withTempConfig(async () => {
process.env.PONYTAIL_HIDE_STATUS = "1";
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
});
await events.get("session_start")({ reason: "resume" }, ctx);
await events.get("agent_start")({}, ctx);
const injected = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.deepEqual(statusWrites, [], "status bar must not be drawn when hidden");
assert.match(injected.systemPrompt, /PONYTAIL MODE ACTIVE/, "ruleset must still inject while status is hidden");
}));
test("config.hideStatus hides the indicator but keeps ponytail active (#324)", async () => withTempConfig(async () => {
mkdirSync(join(process.env.XDG_CONFIG_HOME, "ponytail"), { recursive: true });
writeFileSync(join(process.env.XDG_CONFIG_HOME, "ponytail", "config.json"), JSON.stringify({ hideStatus: true }));
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
});
await events.get("session_start")({ reason: "startup" }, ctx);
await events.get("agent_start")({}, ctx);
const injected = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.deepEqual(statusWrites, [], "config.hideStatus must suppress the status bar");
assert.match(injected.systemPrompt, /PONYTAIL MODE ACTIVE/, "ruleset must still inject while status is hidden");
}));
test("PONYTAIL_HIDE_STATUS=0 does not hide the indicator", async () => withTempConfig(async () => {
process.env.PONYTAIL_HIDE_STATUS = "0";
const { events } = createPiHarness();
const statusWrites = [];
const ctx = createCommandContext({
sessionManager: { getEntries: () => [{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } }] },
ui: { notify() {}, setStatus: (key, text) => statusWrites.push({ key, text }), theme: { fg: (_c, t) => t } },
});
await events.get("session_start")({ reason: "resume" }, ctx);
await events.get("agent_start")({}, ctx);
assert.ok(statusWrites.length > 0, "0 must be treated as 'do not hide'");
}));
+62 -3
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
@@ -8,6 +8,7 @@ import {
filterSkillBodyForMode,
parsePonytailCommand,
readDefaultMode,
readQuietStartup,
resolveSessionMode,
writeDefaultMode,
} from "../index.js";
@@ -22,6 +23,15 @@ test("parsePonytailCommand parses modes, status, and default subcommand", () =>
assert.deepEqual(parsePonytailCommand("default lite", "full"), { type: "set-default", mode: "lite" });
});
test("parsePonytailCommand rejects review as a default (session-only mode, #377)", () => {
assert.deepEqual(parsePonytailCommand("default review", "full"), { type: "invalid", reason: "invalid-default-mode" });
});
test("resolveSessionMode still honors review as a session mode (not a default)", () => {
const entries = [{ type: "custom", customType: "ponytail-mode", data: { mode: "review" } }];
assert.equal(resolveSessionMode(entries, "full"), "review");
});
test("resolveSessionMode prefers latest persisted session mode", () => {
const entries = [
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
@@ -61,8 +71,42 @@ test("readDefaultMode and writeDefaultMode use XDG config path", () => {
}
});
test("readQuietStartup resolves env var, config file, and default in that order", () => {
const tempDir = mkdtempSync(join(tmpdir(), "ponytail-quiet-"));
const previousXdg = process.env.XDG_CONFIG_HOME;
const previousEnv = process.env.PONYTAIL_QUIET_STARTUP;
const configDir = join(tempDir, "ponytail");
const configPath = join(configDir, "config.json");
process.env.XDG_CONFIG_HOME = tempDir;
delete process.env.PONYTAIL_QUIET_STARTUP;
try {
// No env, no config -> default false (toast still shows)
assert.equal(readQuietStartup(), false);
// Config file true -> respected
mkdirSync(configDir, { recursive: true });
writeFileSync(configPath, JSON.stringify({ quietStartup: true }), "utf8");
assert.equal(readQuietStartup(), true);
// Env var overrides config
process.env.PONYTAIL_QUIET_STARTUP = "false";
assert.equal(readQuietStartup(), false);
process.env.PONYTAIL_QUIET_STARTUP = "1";
assert.equal(readQuietStartup(), true);
} finally {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
if (previousEnv === undefined) delete process.env.PONYTAIL_QUIET_STARTUP;
else process.env.PONYTAIL_QUIET_STARTUP = previousEnv;
rmSync(tempDir, { recursive: true, force: true });
}
});
test("filterSkillBodyForMode keeps only requested intensity examples and rows", () => {
const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: Lite example\n- full: Full example\n- ultra: Ultra example\nOther line`;
// Examples are quoted in the real SKILL.md (`- lite: "..."`) — match that
// shape here too; see the next test for why the quote is load-bearing.
const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: "Lite example"\n- full: "Full example"\n- ultra: "Ultra example"\nOther line`;
const filtered = filterSkillBodyForMode(body, "ultra");
@@ -74,6 +118,20 @@ test("filterSkillBodyForMode keeps only requested intensity examples and rows",
assert.ok(filtered.includes("Other line"));
});
test("filterSkillBodyForMode does not drop a rule bullet whose label matches a mode name", () => {
// A rule bullet like "- Full: ..." has the same "label: text" shape as a
// worked example, but isn't one — it must survive in every mode. Only the
// quoted, `- lite: "..."`-style bullets are real per-mode examples.
const body = `- Full: do not confuse this rule label with the mode name.\n- Lite: same risk, this is a real rule bullet.\n- lite: "real worked example"\n- ultra: "real worked example"`;
const filtered = filterSkillBodyForMode(body, "ultra");
assert.ok(filtered.includes("Full: do not confuse"), "an unquoted rule bullet must not be treated as a mode example");
assert.ok(filtered.includes("Lite: same risk"), "an unquoted rule bullet must not be treated as a mode example");
assert.ok(!filtered.includes("- lite:"), "the real quoted lite example must still be filtered out in ultra mode");
assert.ok(filtered.includes('ultra: "real worked example"'));
});
test("filterSkillBodyForMode keeps rule bullets that contain a colon", () => {
// Regression: rule bullets outside the Intensity section (e.g. the
// "No unrequested abstractions:" rule or the `ponytail:` comment convention)
@@ -84,7 +142,8 @@ test("filterSkillBodyForMode keeps rule bullets that contain a colon", () => {
const filtered = filterSkillBodyForMode(body, "full");
assert.ok(filtered.includes("No unrequested abstractions"));
assert.ok(filtered.includes("Mark deliberate simplifications"));
assert.ok(filtered.includes("Mark deliberate simplifications that cut a real corner"));
assert.ok(filtered.includes("`ponytail:` comment naming the ceiling and upgrade path"));
// The Intensity examples are still filtered down to the active mode.
assert.ok(filtered.includes('full: "`@lru_cache'));
assert.ok(!filtered.includes('lite: "Done'));
+3
View File
@@ -0,0 +1,3 @@
{
"name": "ponytail"
}
+21
View File
@@ -0,0 +1,21 @@
name: ponytail
version: 4.9.0
description: Lazy senior dev mode for Hermes Agent, always-on context, bundled skills, and slash commands.
author: Dietrich Gebert
provides_hooks:
- pre_llm_call
- pre_gateway_dispatch
provides_commands:
- ponytail
- ponytail-review
- ponytail-audit
- ponytail-debt
- ponytail-gain
- ponytail-help
provides_skills:
- ponytail
- ponytail-review
- ponytail-audit
- ponytail-debt
- ponytail-gain
- ponytail-help
+5 -1
View File
@@ -3,13 +3,17 @@
// 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 fs from "node:fs";
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 { version } = JSON.parse(
await fs.promises.readFile(new URL("../package.json", import.meta.url), "utf8")
);
const server = new McpServer({ name: "ponytail", version });
const modeArg = z
.enum(MODES)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ponytail-mcp",
"version": "4.8.2",
"version": "4.9.0",
"description": "MCP server that serves Ponytail's lazy-senior-dev instructions as a prompt and a tool.",
"private": true,
"type": "module",
+1 -1
View File
@@ -17,7 +17,7 @@ const ROOT = path.join(__dirname, '..');
const HOMEPAGE = 'https://github.com/DietrichGebert/ponytail';
const DESCRIPTIONS = {
'ponytail': 'Lazy senior dev mode. Forces the simplest, shortest solution that works: YAGNI, stdlib first, no unrequested abstractions.',
'ponytail': 'Lazy senior dev mode for any coding task (write, refactor, fix, review): YAGNI, stdlib first, no unrequested abstractions. Not for non-coding requests.',
'ponytail-review': 'Review a diff for over-engineering. Finds what to delete: reinvented stdlib, needless deps, speculative abstractions. One line per finding.',
'ponytail-audit': 'Audit the whole repo for over-engineering. A ranked list of what to delete, simplify, or replace with stdlib or native features.',
'ponytail-debt': 'Harvest every ponytail: shortcut comment into one debt ledger, so deferrals get tracked instead of forgotten. One-shot report.',
+2
View File
@@ -21,6 +21,7 @@ const copies = [
['.windsurf/rules/ponytail.md', text => text.trim()],
['.clinerules/ponytail.md', text => text.trim()],
['.agents/rules/ponytail.md', text => text.trim()],
['.qoder/rules/ponytail.md', text => text.trim()],
['.github/copilot-instructions.md', text => text.trim()],
['.kiro/steering/ponytail.md', stripFrontmatter],
];
@@ -41,6 +42,7 @@ for (const [relPath, normalize] of copies) {
// a rule's wording trips this, which is the reminder to propagate it everywhere.
// Upgrade path: generate the copies from SKILL.md if this ever misses a real drift.
const INVARIANTS = [
'in this codebase', // ladder rung: reuse what already exists (#217)
'naive heuristic', // ceiling-comment rule
'ONE runnable check', // test reflex
'flimsier algorithm', // robust-variant rule
+10 -8
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Version-consistency guard. Ponytail declares its version in six files across
// four host ecosystems, and every release bumps all of them by hand.
// Version-consistency guard. Ponytail declares its version in seven files across
// five host ecosystems, and every release bumps all of them by hand.
//
// tests/gemini-extension.test.js already checks the four plugin manifests agree
// with each other, but that can't catch the failure mode that shipped in v4.8.0:
@@ -19,12 +19,14 @@ const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;
// Every file that declares the project version, and who reads it. Add new host
// manifests here so a future ecosystem can't drift unnoticed.
const VERSION_FILES = [
'.claude-plugin/plugin.json', // Claude Code plugin — what users install
'.codex-plugin/plugin.json', // Codex plugin
'.github/plugin/plugin.json', // Copilot plugin
'gemini-extension.json', // Gemini CLI extension
'package.json', // pi-package / repo root
'ponytail-mcp/package.json', // MCP server (private, internal-only)
'.claude-plugin/plugin.json', // Claude Code plugin — what users install
'.codex-plugin/plugin.json', // Codex plugin
'.devin-plugin/plugin.json', // Devin CLI plugin
'.github/plugin/plugin.json', // Copilot plugin
'.qoder-plugin/plugin.json', // Qoder plugin
'gemini-extension.json', // Gemini CLI extension
'package.json', // pi-package / repo root
'ponytail-mcp/package.json', // MCP server (private, internal-only)
];
function readVersion(relPath) {
+29 -9
View File
@@ -9,6 +9,8 @@ const fs = require('fs');
const path = require('path');
const { getConfigPath, getClaudeDir } = require('../hooks/ponytail-config');
const STATUSLINE_SCRIPT = 'ponytail-statusline';
function removeIfExists(filePath, label) {
try {
fs.unlinkSync(filePath);
@@ -26,15 +28,33 @@ try {
const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, '');
const settings = JSON.parse(raw);
const cmd = settings.statusLine && settings.statusLine.command;
// ponytail: substring-match the script name, then drop the whole statusLine
// key. A combined statusline (e.g. caveman+ponytail) whose command contains
// "ponytail-statusline" gets removed wholesale. Parse out only ponytail's part
// if combined statuslines become common.
if (typeof cmd === 'string' && cmd.includes('ponytail-statusline')) {
delete settings.statusLine;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
console.log(`Removed ponytail statusLine entry from ${settingsPath}`);
// Only remove the parts ponytail owns. If the user combined statuslines
// (e.g. caveman && ponytail), keep the other plugin's command intact.
// ponytail: splits on && / ; to detect other segments — good enough; a user
// piping statuslines together is on their own.
if (typeof cmd === 'string' && cmd.includes(STATUSLINE_SCRIPT)) {
const parts = cmd
.split(/&&|;/)
.map((s) => s.trim())
.filter(Boolean);
const others = parts.filter((s) => !s.includes(STATUSLINE_SCRIPT));
if (others.length === 0) {
delete settings.statusLine;
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
console.log(`Removed ponytail statusLine entry from ${settingsPath}`);
} else {
settings.statusLine.command = others.join(' && ');
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
console.log(`Removed ponytail statusLine segment from ${settingsPath}`);
}
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
if (e.code === 'ENOENT') {
// no settings.json — nothing to clean
} else if (e instanceof SyntaxError) {
// ponytail: malformed settings.json — can't safely edit it; leave intact, warn
console.warn(`settings.json is malformed — could not remove the ponytail statusLine entry. Remove it manually from: ${settingsPath} (${e.message})`);
} else {
throw e;
}
}
+4 -2
View File
@@ -27,12 +27,14 @@ Level sticks until changed or session end.
|-------|---------|--------------|
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
| **ponytail-audit** | `/ponytail-audit` | Whole-repo over-engineering audit: ranked list of what to delete. |
| **ponytail-debt** | `/ponytail-debt` | Harvest `ponytail:` shortcut comments into a tracked ledger. |
| **ponytail-gain** | `/ponytail-gain` | Measured-impact scoreboard: less code, less cost, more speed. |
| **ponytail-help** | `/ponytail-help` | This card. |
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
`/ponytail-review`).
and OpenCode use the slash-command forms above (OpenCode ships all six as
slash commands).
## Deactivate
+9 -6
View File
@@ -5,11 +5,14 @@ description: >
minimal. Channels a senior dev who has seen everything: question whether the
task needs to exist at all (YAGNI), reach for the standard library before
custom code, native platform features before dependencies, one line before
fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
"minimal solution", "yagni", "do less", or "shortest path", and whenever
they complain about over-engineering, bloat, boilerplate, or unnecessary
dependencies.
fifty. Supports intensity levels: lite, full (default), ultra. Use on ANY
coding task: writing, adding, refactoring, fixing, reviewing, or designing
code, and choosing libraries or dependencies. Also use whenever the user
says "ponytail", "be lazy", "lazy mode", "simplest solution", "minimal
solution", "yagni", "do less", or "shortest path", or complains about
over-engineering, bloat, boilerplate, or unnecessary dependencies. Do NOT
use for non-coding requests (general knowledge, prose, translation,
summaries, recipes).
argument-hint: "[lite|full|ultra]"
license: MIT
---
@@ -58,7 +61,7 @@ every sibling caller still broken. Fix it once, where all callers route through.
- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path (`# ponytail: global lock, per-account locks if throughput matters`).
## Output
+2
View File
@@ -13,6 +13,8 @@ const REQUIRED_COMMAND_FILES = [
'ponytail-review.toml',
'ponytail-audit.toml',
'ponytail-debt.toml',
'ponytail-gain.toml',
'ponytail-help.toml',
];
function readJSON(relPath) {
+30
View File
@@ -106,6 +106,36 @@ test('csv: value containing 351 as substring fails (e.g. 13510)', () => {
assert.equal(result.score, 0);
});
test('csv: timeout can be raised for slow pandas startup', () => {
const previous = process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS;
try {
process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS = '1';
const timedOut = check(
"Write Python code that reads sales.csv and sums the 'amount' column.",
'python',
`import time
time.sleep(0.05)
print(351)`,
);
assert.equal(timedOut.pass, false);
assert.match(timedOut.reason, /ETIMEDOUT|timed out/i);
process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS = '1000';
const completed = check(
"Write Python code that reads sales.csv and sums the 'amount' column.",
'python',
`import time
time.sleep(0.05)
print(351)`,
);
assert.equal(completed.pass, true);
assert.equal(completed.score, 1);
} finally {
if (previous === undefined) delete process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS;
else process.env.PONYTAIL_CORRECTNESS_TIMEOUT_MS = previous;
}
});
// --- React countdown ---
test('countdown: valid React component passes', () => {
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
// Grok Build loads Ponytail through its native skill system. Lifecycle-hook
// stdout is passive in Grok, so this adapter must not register any hooks.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
test('Grok manifest is a skill-only adapter with no lifecycle hooks', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8'));
assert.equal(manifest.name, 'ponytail');
assert.equal(manifest.hooks, undefined);
assert.equal(manifest.mcpServers, undefined);
assert.ok(!fs.existsSync(path.join(root, 'hooks', 'hooks.json')));
assert.ok(!fs.existsSync(path.join(root, '.grok-plugin', 'hooks.json')));
});
test('Ponytail skill describes every coding task for Grok auto-invocation', () => {
const skill = fs.readFileSync(path.join(root, 'skills', 'ponytail', 'SKILL.md'), 'utf8');
assert.match(skill, /Use on ANY\s+coding task/i);
assert.match(skill, /writing, adding, refactoring, fixing, reviewing, or designing\s+code/i);
assert.doesNotMatch(skill, /disable-model-invocation:\s*true/i);
});
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
// Hermes support is a real plugin, not just copied rules: the repo root must be
// installable with `hermes plugins install owner/repo`, register bundled skills,
// inject active mode context, and expose slash commands.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const commands = ['ponytail', 'ponytail-review', 'ponytail-audit', 'ponytail-debt', 'ponytail-gain', 'ponytail-help'];
const skillCommands = commands.filter((name) => name !== 'ponytail');
const root = path.join(__dirname, '..');
// ponytail: probe once; on Windows `python3` is the Store-alias stub that fails
// even when Python is installed, so fall back to `python` (mirrors benchmarks/correctness.js).
let pythonCmd;
function pythonExe() {
if (pythonCmd) return pythonCmd;
for (const cmd of ['python3', 'python']) {
if (spawnSync(cmd, ['-c', 'import sys'], { encoding: 'utf8' }).status === 0) {
return (pythonCmd = cmd);
}
}
return (pythonCmd = 'python3');
}
function python(script, env = {}) {
const result = spawnSync(pythonExe(), ['-c', script], {
cwd: root,
env: { ...process.env, ...env },
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(`python failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`);
}
return result.stdout.trim();
}
test('Hermes plugin manifest matches runtime skills, hooks, commands, and package version', () => {
const manifestPath = path.join(root, 'plugin.yaml');
assert.ok(fs.existsSync(manifestPath), 'missing root plugin.yaml');
const manifest = fs.readFileSync(manifestPath, 'utf8');
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const skillDirs = fs.readdirSync(path.join(root, 'skills'))
.filter((name) => fs.existsSync(path.join(root, 'skills', name, 'SKILL.md')))
.sort();
assert.match(manifest, /^name:\s*ponytail$/m);
assert.match(manifest, new RegExp(`^version:\\s*${packageJson.version}$`, 'm'));
assert.match(manifest, new RegExp(`^author:\\s*${packageJson.author.name}$`, 'm'));
assert.deepEqual(commands.filter((name) => manifest.includes(` - ${name}`)), commands);
assert.deepEqual(skillDirs.filter((name) => manifest.includes(` - ${name}`)), skillDirs);
assert.match(manifest, /pre_llm_call/);
assert.match(manifest, /pre_gateway_dispatch/);
});
test('Hermes plugin registers every shipped skill under the ponytail namespace', () => {
const output = python(String.raw`
import importlib.util, json, pathlib
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
class Ctx:
def __init__(self):
self.skills = []
self.hooks = []
self.commands = []
def register_skill(self, name, path):
self.skills.append((name, pathlib.Path(path).as_posix()))
def register_hook(self, name, handler):
self.hooks.append(name)
def register_command(self, name, handler, description='', args_hint=''):
self.commands.append(name)
ctx = Ctx()
mod.register(ctx)
print(json.dumps({'skills': ctx.skills, 'hooks': ctx.hooks, 'commands': ctx.commands}, sort_keys=True))
`);
const data = JSON.parse(output);
assert.deepEqual(data.skills.map(([name]) => name).sort(), [
'ponytail',
'ponytail-audit',
'ponytail-debt',
'ponytail-gain',
'ponytail-help',
'ponytail-review',
]);
assert.ok(data.skills.every(([, skillPath]) => skillPath.endsWith('/SKILL.md')));
assert.ok(data.hooks.includes('pre_llm_call'));
assert.ok(data.commands.includes('ponytail'));
assert.ok(data.commands.includes('ponytail-review'));
});
test('Hermes plugin builds mode-aware injected context from the canonical skill', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
const output = python(String.raw`
import importlib.util, json
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
ctx = mod.build_injected_context('ultra')
print(json.dumps({'ctx': ctx}))
`, { XDG_CONFIG_HOME: tmp });
const { ctx } = JSON.parse(output);
assert.match(ctx, /PONYTAIL MODE ACTIVE — level: ultra/);
assert.match(ctx, /The best\s+code is the code never written/);
assert.match(ctx, /ultra/i);
assert.doesNotMatch(ctx, /^---/);
assert.doesNotMatch(ctx, /\|\s*\*\*Lite\*\*/i);
});
test('Hermes mode config respects env, config file, off, and invalid command behavior', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-config-'));
fs.mkdirSync(path.join(tmp, 'ponytail'), { recursive: true });
fs.writeFileSync(path.join(tmp, 'ponytail', 'config.json'), JSON.stringify({ defaultMode: 'lite' }));
const output = python(String.raw`
import importlib.util, json
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
class Ctx:
def __init__(self): self.commands = {}
def register_skill(self, name, path): pass
def register_hook(self, name, handler): pass
def register_command(self, name, handler, description='', args_hint=''):
self.commands[name] = handler
ctx = Ctx()
mod.register(ctx)
status_before = ctx.commands['ponytail']('')
invalid = ctx.commands['ponytail']('maximum')
status_after = ctx.commands['ponytail']('')
print(json.dumps({
'default': mod.build_injected_context(None),
'off': mod.build_injected_context('off'),
'status_before': status_before,
'invalid': invalid,
'status_after': status_after,
}))
`, { XDG_CONFIG_HOME: tmp, PONYTAIL_DEFAULT_MODE: 'ultra' });
const data = JSON.parse(output);
assert.match(data.default, /level: ultra/);
assert.equal(data.off, '');
assert.match(data.status_before, /Ponytail mode: ultra/);
assert.match(data.invalid, /Usage:/);
assert.match(data.status_after, /Ponytail mode: ultra/);
});
test('Hermes plugin review mode injects the real review skill body', () => {
const output = python(String.raw`
import importlib.util, json
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
ctx = mod.build_injected_context('review')
print(json.dumps({'ctx': ctx}))
`);
const { ctx } = JSON.parse(output);
assert.match(ctx, /PONYTAIL MODE ACTIVE — level: review/);
assert.match(ctx, /Review diffs for unnecessary complexity/);
assert.match(ctx, /net: -<N> lines possible/);
assert.doesNotMatch(ctx, /^---/);
});
test('Hermes /ponytail command changes mode and pre_llm_call injects current context', () => {
const output = python(String.raw`
import importlib.util, json
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
class Ctx:
def __init__(self):
self.hooks = {}
self.commands = {}
def register_skill(self, name, path): pass
def register_hook(self, name, handler): self.hooks[name] = handler
def register_command(self, name, handler, description='', args_hint=''):
self.commands[name] = handler
ctx = Ctx()
mod.register(ctx)
message = ctx.commands['ponytail']('ultra')
injected = ctx.hooks['pre_llm_call'](session_id='s1', user_message='build it', conversation_history=[], is_first_turn=False, model='m', platform='cli')
print(json.dumps({'message': message, 'context': injected['context']}))
`);
const data = JSON.parse(output);
assert.match(data.message, /ultra/);
assert.match(data.context, /PONYTAIL MODE ACTIVE — level: ultra/);
});
test('Hermes gateway rewrite respects slash access denial', () => {
const output = python(String.raw`
import importlib.util, json
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
class Source:
platform = None
chat_id = 'c1'
user_id = 'u1'
class Event:
text = '/ponytail-review src/app.js'
source = Source()
class Gateway:
def _check_slash_access(self, source, command):
return 'denied'
result = mod.rewrite_gateway_command(event=Event(), gateway=Gateway())
print(json.dumps(result))
`);
assert.equal(output, 'null');
});
test('Hermes gateway rewrite preserves every skill command and ignores unrelated text', () => {
const output = python(String.raw`
import importlib.util, json
spec = importlib.util.spec_from_file_location('ponytail_hermes_plugin', '__init__.py')
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
class Event:
def __init__(self, text): self.text = text
cases = {}
for text in ['/ponytail-review x', '/ponytail_audit repo', '/ponytail-debt', '/ponytail-help', '/status', 'hello']:
cases[text] = mod.rewrite_gateway_command(event=Event(text))
print(json.dumps(cases, sort_keys=True))
`);
const data = JSON.parse(output);
assert.match(data['/ponytail-review x'].text, /ponytail-review/);
assert.match(data['/ponytail_audit repo'].text, /ponytail-audit/);
assert.match(data['/ponytail_audit repo'].text, /repo/);
assert.match(data['/ponytail-debt'].text, /ponytail-debt/);
assert.match(data['/ponytail-help'].text, /ponytail-help/);
assert.equal(data['/status'], null);
assert.equal(data.hello, null);
});
+54 -21
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env node
// Regression test for issue #19: on Windows the lifecycle hooks run via
// PowerShell, which does NOT expand cmd.exe-style %VAR% — it needs $env:VAR.
// Regression test for issues #19 and #593: on Windows the lifecycle hooks run
// via PowerShell, so the shared `command` field must be cross-platform (plain
// `node`, no bash-only syntax). commandWindows is not part of the supported
// hooks schema on the Claude.ai plugin marketplace validator, so it is omitted
// — ${CLAUDE_PLUGIN_ROOT} expansion and `node` work everywhere.
//
// The hook also has to point at a script that actually ships in hooks/.
// This guards both failure modes: the original %CLAUDE_PLUGIN_ROOT% bug, and
// the "switch to a .ps1 that doesn't exist" mistake.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const root = path.join(__dirname, '..');
const HOOKS_JSON = 'hooks/claude-codex-hooks.json';
@@ -16,8 +19,6 @@ const HOST_PLUGIN_MANIFESTS = [
'.claude-plugin/plugin.json',
'.codex-plugin/plugin.json',
];
// cmd.exe variable syntax (%FOO%); PowerShell leaves it literal, breaking the path.
const CMD_VAR_SYNTAX = /%[A-Za-z_][A-Za-z0-9_]*%/;
// PowerShell 5.1 rejects these POSIX shell guards when a host runs `command`.
const POSIX_GUARD_SYNTAX = /\bcommand\s+-v\b|&&|\|\||>\/dev\/null|2>&1/;
// Pull the hooks/<script> a command launches, so we can check it exists.
@@ -32,13 +33,14 @@ function commandHooks() {
.flatMap((entry) => entry.hooks);
}
test('every commandWindows uses PowerShell $env: syntax, not cmd.exe %VAR%', () => {
const windowsCommands = commandHooks()
.map((h) => h.commandWindows)
.filter(Boolean);
assert.ok(windowsCommands.length > 0, 'expected at least one commandWindows entry');
for (const cmd of windowsCommands) {
assert.doesNotMatch(cmd, CMD_VAR_SYNTAX, `commandWindows uses cmd.exe %VAR% (breaks under PowerShell): ${cmd}`);
// commandWindows is not part of the supported hooks schema on the Claude.ai
// plugin marketplace validator (#593). Since the shared `command` field
// already runs cross-platform (Claude Code expands ${CLAUDE_PLUGIN_ROOT}
// before the shell sees it, and VS Code Copilot ignores commandWindows and
// runs `command` through PowerShell on Windows anyway), it is omitted.
test('hooks.json omits commandWindows for marketplace validation (#593)', () => {
for (const hook of commandHooks()) {
assert.equal(hook.commandWindows, undefined, `hook must not use commandWindows (not supported by marketplace validator): ${hook.command}`);
}
});
@@ -52,27 +54,58 @@ test('shared hook commands avoid POSIX-only guard syntax', () => {
}
});
test('shared hook commands keep lifecycle hooks non-blocking', () => {
// Issue #527 / #569: the shared `command` field must be shell-agnostic. `exec`
// is a bash/zsh builtin with no PowerShell equivalent, but some hosts run
// `command` through PowerShell on Windows regardless of the commandWindows
// field — VS Code Copilot always does (it never reads commandWindows), and
// native Claude Code launched from Git Bash was seen doing the same. `exec
// node ...` then dies on its first token with CommandNotFoundException, so
// every hook fails on Windows. Plain `node ...` runs natively in both bash and
// PowerShell. The wrapper-process pileup that #461 originally used `exec` to
// avoid is handled separately by each hook's stdin self-exit guard (#443/#477).
test('shared hook commands are shell-agnostic (no bash-only exec prefix)', () => {
const commands = commandHooks()
.map((h) => h.command)
.filter(Boolean);
assert.ok(commands.length > 0, 'expected at least one shared command entry');
for (const cmd of commands) {
assert.match(cmd, /;\s*exit 0$/, `command must exit successfully if node or the hook script fails: ${cmd}`);
assert.doesNotMatch(cmd, /(^|\s)exec\s/, `command must not use the bash-only 'exec' builtin (breaks under PowerShell): ${cmd}`);
assert.match(cmd, /^node\s+/, `command must invoke node directly so it runs in both bash and PowerShell: ${cmd}`);
assert.doesNotMatch(cmd, /;\s*exit 0$/, `command must not leave a shell wrapper waiting on node: ${cmd}`);
}
});
test('every hook command points at a script that ships in hooks/', () => {
for (const hook of commandHooks()) {
for (const cmd of [hook.command, hook.commandWindows].filter(Boolean)) {
const match = cmd.match(HOOK_SCRIPT);
assert.ok(match, `cannot find a hooks/ script in command: ${cmd}`);
const script = path.join(root, 'hooks', match[1]);
assert.ok(fs.existsSync(script), `command references a missing hook script: ${match[1]}`);
}
const cmd = hook.command;
const match = cmd.match(HOOK_SCRIPT);
assert.ok(match, `cannot find a hooks/ script in command: ${cmd}`);
const script = path.join(root, 'hooks', match[1]);
assert.ok(fs.existsSync(script), `command references a missing hook script: ${match[1]}`);
}
});
// Issue #443: on Windows the UserPromptSubmit hook runs inside a PowerShell
// `if {}` wrapper that can swallow the piped prompt JSON, so stdin 'end' never
// fires. The hook must never wait on stdin forever — that freezes the whole
// session. It has to self-exit even when stdin stays open and empty.
test('ponytail-mode-tracker self-exits when stdin never closes (no freeze)', async () => {
const hook = path.join(root, 'hooks', 'ponytail-mode-tracker.js');
// stdin is a pipe we never write to or end, reproducing the deadlock.
const child = spawn(process.execPath, [hook], { stdio: ['pipe', 'ignore', 'ignore'] });
const code = await new Promise((resolve, reject) => {
const guard = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('hook hung on open stdin — it would freeze the session'));
}, 3000);
child.on('exit', (c) => { clearTimeout(guard); resolve(c); });
child.on('error', reject);
});
assert.equal(code, 0, 'hook must exit cleanly when stdin never closes');
});
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'));
+324 -3
View File
@@ -11,7 +11,7 @@ const root = path.join(__dirname, '..');
// isShellSafe gates the statusline setup snippet (issue #200): ordinary install
// paths pass, paths carrying shell metacharacters are rejected so they never get
// embedded in a shell command.
const { isShellSafe } = require('../hooks/ponytail-config');
const { DEFAULT_MODE, getDefaultMode, isShellSafe, writeDefaultMode } = require('../hooks/ponytail-config');
assert.equal(isShellSafe('C:\\Users\\x\\.claude\\plugins\\ponytail\\hooks\\ponytail-statusline.ps1'), true);
assert.equal(isShellSafe('/home/u/.claude/plugins/ponytail/hooks/ponytail-statusline.sh'), true);
assert.equal(isShellSafe('/tmp/a"&calc.exe&"/x.sh'), false);
@@ -26,9 +26,17 @@ function run(script, env, input = '') {
});
}
// Keep the base env clean so the default-dir checks are deterministic; the
// CLAUDE_CONFIG_DIR case sets it explicitly.
// Keep the base env clean so the default-dir / native-Claude checks are
// deterministic; the CLAUDE_CONFIG_DIR and codex/copilot cases set these
// explicitly where needed. run() spreads process.env, so a PLUGIN_DATA /
// COPILOT_PLUGIN_DATA leaked from the dev or CI shell would otherwise steer
// writeHookOutput into the wrong branch and mis-fire the native assertions.
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.PLUGIN_DATA;
delete process.env.COPILOT_PLUGIN_DATA;
// A leaked subagent matcher would scope the inject-into-every-subagent assertions.
delete process.env.PONYTAIL_SUBAGENT_MATCHER;
delete process.env.QODER_SESSION_ID;
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-'));
// Runs on normal exit and on assertion-throw exit; force makes it idempotent.
@@ -52,6 +60,8 @@ assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'ultra');
let output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:ULTRA');
assert.equal(output.additionalContext, undefined, 'Codex must not emit additionalContext at top level (#573)');
assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: ultra/,
@@ -67,6 +77,22 @@ assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite');
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:LITE');
// Querying bare @ponytail should report the active level ('lite') without resetting it to default ('ultra')
result = run(
'ponytail-mode-tracker.js',
codexEnv,
JSON.stringify({ prompt: '@ponytail' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite');
output = JSON.parse(result.stdout);
assert.equal(output.additionalContext, undefined, 'Codex must not emit additionalContext at top level (#573)');
assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: lite/,
);
result = run(
'ponytail-mode-tracker.js',
codexEnv,
@@ -128,6 +154,30 @@ assert.equal(
false,
'flag must not land in ~/.claude when CLAUDE_CONFIG_DIR is set',
);
// The statusline setup nudge must point at the configured settings.json, not a
// hardcoded ~/.claude (issue #250).
assert.ok(
result.stdout.includes(path.join(customConfigDir, 'settings.json')),
'statusline nudge must reference the CLAUDE_CONFIG_DIR settings.json',
);
// #483: the statusline nudge fires at most once — after it writes its flag, a
// later session stays silent instead of re-nagging on every start.
assert.ok(
fs.existsSync(path.join(customConfigDir, '.ponytail-statusline-nudged')),
'first nudge must write the once-only flag (#483)',
);
const secondNudge = run('ponytail-activate.js', {
HOME: home2,
USERPROFILE: home2,
CLAUDE_CONFIG_DIR: customConfigDir,
PONYTAIL_DEFAULT_MODE: 'lite',
});
assert.equal(secondNudge.status, 0, secondNudge.stderr);
assert.ok(
!secondNudge.stdout.includes('STATUSLINE SETUP NEEDED'),
'nudge must not repeat once the flag file exists (#483)',
);
const copilotData = path.join(temp, 'copilot-data');
const codexData = path.join(temp, 'codex-data-shadow');
@@ -148,6 +198,36 @@ assert.equal(
output = JSON.parse(result.stdout);
assert.match(output.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
// VS Code Copilot never sets COPILOT_PLUGIN_DATA — it only injects
// CLAUDE_PLUGIN_ROOT pointed at an agent-plugins/.../.vscode install path
// (#528). Without a fallback, isCopilot was false, so ponytail assumed
// native Claude Code and emitted the statusline nudge — noise, since VS
// Code Copilot doesn't read Claude's statusLine setting.
const vscodeHome = path.join(temp, 'vscode-copilot-home');
const vscodePluginRoot = path.join(
vscodeHome, '.vscode', 'agent-plugins', 'github.com', 'DietrichGebert', 'ponytail', 'hooks',
);
fs.mkdirSync(vscodeHome, { recursive: true });
result = run('ponytail-activate.js', {
HOME: vscodeHome,
USERPROFILE: vscodeHome,
CLAUDE_PLUGIN_ROOT: vscodePluginRoot,
PONYTAIL_DEFAULT_MODE: 'full',
});
assert.equal(result.status, 0, result.stderr);
assert.ok(
!result.stdout.includes('STATUSLINE SETUP NEEDED'),
'VS Code Copilot (detected via CLAUDE_PLUGIN_ROOT) must not get the Claude-only statusline nudge',
);
// isCopilot must still resolve a state dir even though COPILOT_PLUGIN_DATA
// is unset under VS Code — falling back to ~/.claude, not crashing on an
// undefined path.
assert.equal(
fs.readFileSync(path.join(vscodeHome, '.claude', '.ponytail-active'), 'utf8'),
'full',
'VS Code Copilot must persist mode state under getClaudeDir(), not a path built from the unset COPILOT_PLUGIN_DATA',
);
result = run(
'ponytail-mode-tracker.js',
{
@@ -168,4 +248,245 @@ assert.equal(
output = JSON.parse(result.stdout);
assert.deepEqual(output, {});
// SubagentStart hook: when ponytail mode is active it injects the ruleset into
// each subagent (issue #252). Native Claude must get the hookSpecificOutput JSON
// form, not raw stdout, or the context is dropped.
const subHome = path.join(temp, 'sub-home');
const subFlag = path.join(subHome, '.claude', '.ponytail-active');
fs.mkdirSync(path.dirname(subFlag), { recursive: true });
const subEnv = { HOME: subHome, USERPROFILE: subHome };
fs.writeFileSync(subFlag, 'full');
result = run('ponytail-subagent.js', subEnv);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
);
// No flag → ponytail off → inject nothing (empty stdout, no failure).
fs.unlinkSync(subFlag);
result = run('ponytail-subagent.js', subEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '', 'SubagentStart must stay silent when ponytail is off');
// Codex shares claude-codex-hooks.json, so SubagentStart is reachable under Codex
// too — assert the codex branch emits the badge plus hookSpecificOutput.
const subCodex = path.join(temp, 'sub-codex');
fs.mkdirSync(subCodex, { recursive: true });
fs.writeFileSync(path.join(subCodex, '.ponytail-active'), 'full');
result = run('ponytail-subagent.js', { HOME: subHome, USERPROFILE: subHome, PLUGIN_DATA: subCodex });
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:FULL');
assert.equal(output.additionalContext, undefined, 'Codex must not emit additionalContext at top level (#573)');
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
// SubagentStart scoping (issue #506): PONYTAIL_SUBAGENT_MATCHER limits the
// injection to agent types whose name matches the regex. Unset keeps the
// inject-into-every-subagent behavior asserted above. The matcher is
// case-insensitive and unanchored, and every uncertain case fails open.
const scopeHome = path.join(temp, 'scope-home');
const scopeFlag = path.join(scopeHome, '.claude', '.ponytail-active');
fs.mkdirSync(path.dirname(scopeFlag), { recursive: true });
fs.writeFileSync(scopeFlag, 'full');
const scopeEnv = { HOME: scopeHome, USERPROFILE: scopeHome };
// Matching agent_type → inject; the match is case-insensitive.
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'general|plan' },
JSON.stringify({ agent_type: 'General-purpose' }),
);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
// agent_type the matcher rejects → stay silent.
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'general|plan' },
JSON.stringify({ agent_type: 'Explore' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '', 'a non-matching agent_type must skip the injection');
// Anchored regex → exact match only; a superset name is rejected.
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: '^general$' },
JSON.stringify({ agent_type: 'general-purpose' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, '', 'an anchored matcher must not match a superset agent_type');
// Matcher set but agent_type absent → the platform didn't report it; fail
// open and inject rather than silently dropping the persona (issue #252).
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' },
JSON.stringify({}),
);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
// Invalid regex → must not crash; fall back to injecting everywhere.
result = run(
'ponytail-subagent.js',
{ ...scopeEnv, PONYTAIL_SUBAGENT_MATCHER: '(' },
JSON.stringify({ agent_type: 'anything' }),
);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
// The default (no matcher) path must not depend on stdin: even with stdin
// closed empty it injects synchronously, preserving the #252 behavior on
// Windows where the piped JSON can be swallowed (#443).
result = run('ponytail-subagent.js', scopeEnv, '');
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/);
// Qoder: no SessionStart event, so UserPromptSubmit does double duty —
// it activates the default mode on first prompt (writes flag), then injects
// the ruleset via additionalContext on every prompt. Output is
// hookSpecificOutput JSON (same shape as Codex minus systemMessage).
const qoderHome = path.join(temp, 'qoder-home');
const qoderState = path.join(qoderHome, '.qoder', '.ponytail-active');
fs.mkdirSync(qoderHome, { recursive: true });
const qoderEnv = {
HOME: qoderHome,
USERPROFILE: qoderHome,
QODER_SESSION_ID: 'test-session-123',
PONYTAIL_DEFAULT_MODE: 'full',
};
// First prompt: no flag file yet → mode-tracker initializes from default,
// writes flag, and injects the ruleset.
result = run(
'ponytail-mode-tracker.js',
qoderEnv,
JSON.stringify({ prompt: 'write a function' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(qoderState, 'utf8'), 'full');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
);
// /ponytail ultra: mode tracker updates flag and injects ultra ruleset.
result = run(
'ponytail-mode-tracker.js',
qoderEnv,
JSON.stringify({ prompt: '/ponytail ultra' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(qoderState, 'utf8'), 'ultra');
output = JSON.parse(result.stdout);
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE CHANGED — level: ultra/,
);
// "stop ponytail": deactivates, clears flag, no ruleset output.
result = run(
'ponytail-mode-tracker.js',
qoderEnv,
JSON.stringify({ prompt: 'stop ponytail' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(qoderState), false, 'flag must be cleared after stop ponytail');
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.additionalContext, 'PONYTAIL MODE OFF');
// Subagent injection via PreToolUse (task|Task matcher): when ponytail is
// active, the subagent hook injects the ruleset. Qoder shares the same
// ponytail-subagent.js script; the isQoder branch outputs hookSpecificOutput
// JSON instead of raw stdout.
fs.writeFileSync(qoderState, 'full');
result = run('ponytail-subagent.js', qoderEnv);
assert.equal(result.status, 0, result.stderr);
output = JSON.parse(result.stdout);
assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: full/,
);
// writeDefaultMode must merge into existing config, not overwrite it (#490).
const mergeHome = path.join(temp, 'merge-home');
const mergeConfigDir = path.join(mergeHome, '.config', 'ponytail');
fs.mkdirSync(mergeConfigDir, { recursive: true });
const mergeConfigPath = path.join(mergeConfigDir, 'config.json');
fs.writeFileSync(mergeConfigPath, JSON.stringify({ defaultMode: 'full', customSetting: 42 }, null, 2));
const prevXdg = process.env.XDG_CONFIG_HOME;
process.env.XDG_CONFIG_HOME = path.join(mergeHome, '.config');
try {
writeDefaultMode('ultra');
const merged = JSON.parse(fs.readFileSync(mergeConfigPath, 'utf8'));
assert.equal(merged.defaultMode, 'ultra', 'writeDefaultMode must update defaultMode');
assert.equal(merged.customSetting, 42, 'writeDefaultMode must preserve existing config fields');
} finally {
if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = prevXdg;
}
// #329: `/ponytail default <mode>` persists the default to config (survives
// restart), while a plain switch stays session-scoped and never touches config.
const defHome = path.join(temp, 'default-cmd-home');
const defEnv = { HOME: defHome, USERPROFILE: defHome, XDG_CONFIG_HOME: path.join(defHome, '.config') };
const defConfig = path.join(defHome, '.config', 'ponytail', 'config.json');
const defFlag = path.join(defHome, '.claude', '.ponytail-active');
result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default lite' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', '/ponytail default must persist the default');
assert.equal(fs.existsSync(defFlag), false, '/ponytail default must not change the session mode');
// A plain switch is transient: sets the session flag, leaves the default alone.
result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail ultra' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(defFlag, 'utf8'), 'ultra', 'plain switch must set the session mode');
assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'plain switch must not persist the default');
// review is not a valid default (#377) — the command is ignored, config unchanged.
result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default review' }));
assert.equal(result.status, 0, result.stderr);
assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'review must not be accepted as a default');
// review must be refused as a default by the config functions too, not only the
// mode-tracker command path (#377): writing it is a no-op, and a stray
// PONYTAIL_DEFAULT_MODE=review falls back to the built-in default.
const revHome = path.join(temp, 'review-default-home');
const revConfigDir = path.join(revHome, '.config', 'ponytail');
fs.mkdirSync(revConfigDir, { recursive: true });
const revConfigPath = path.join(revConfigDir, 'config.json');
fs.writeFileSync(revConfigPath, JSON.stringify({ defaultMode: 'lite' }, null, 2));
const prevXdgRev = process.env.XDG_CONFIG_HOME;
const prevEnvModeRev = process.env.PONYTAIL_DEFAULT_MODE;
process.env.XDG_CONFIG_HOME = path.join(revHome, '.config');
try {
assert.equal(writeDefaultMode('review'), null, 'writeDefaultMode must refuse review as a default (#377)');
assert.equal(JSON.parse(fs.readFileSync(revConfigPath, 'utf8')).defaultMode, 'lite', 'a refused review write must leave the config unchanged');
delete process.env.PONYTAIL_DEFAULT_MODE;
fs.rmSync(revConfigPath);
process.env.PONYTAIL_DEFAULT_MODE = 'review';
assert.equal(getDefaultMode(), DEFAULT_MODE, 'PONYTAIL_DEFAULT_MODE=review must fall back to the built-in default');
} finally {
if (prevXdgRev === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prevXdgRev;
if (prevEnvModeRev === undefined) delete process.env.PONYTAIL_DEFAULT_MODE; else process.env.PONYTAIL_DEFAULT_MODE = prevEnvModeRev;
}
console.log('hook compatibility checks passed');
+22 -1
View File
@@ -23,7 +23,11 @@ test.before(async () => {
const url = pathToFileURL(path.join(__dirname, '..', '.opencode', 'plugins', 'ponytail.mjs'));
const mod = await import(url);
loadPlugin = mod.default;
parseCommandFile = mod.parseCommandFile;
// The frontmatter parser used to be exported from the plugin module itself.
// OpenCode's legacy loader treats every exported function as a plugin and
// tried to invoke it with the plugin context object, which crashed. The
// parser now lives in its own .cjs sibling; require it directly.
parseCommandFile = require(path.join(__dirname, '..', '.opencode', 'plugins', 'ponytail-frontmatter.cjs')).parseCommandFile;
});
function transform(hooks) {
@@ -56,6 +60,23 @@ test('/ponytail off persists off and transform injects nothing', async () => {
assert.deepEqual(system, []);
});
test('system.transform merges into existing system entry (Qwen compat, #296)', async () => {
try { fs.unlinkSync(statePath); } catch (e) {}
const hooks = await loadPlugin({});
const output = { system: ['You are a helpful assistant.'] };
await hooks['experimental.chat.system.transform']({ model: {} }, output);
assert.equal(output.system.length, 1, 'must not add a second system entry');
assert.match(output.system[0], /You are a helpful assistant/);
assert.match(output.system[0], /PONYTAIL MODE ACTIVE/);
});
test('unsupported /ponytail arguments do not reset the current mode', async () => {
const hooks = await loadPlugin({});
fs.writeFileSync(statePath, 'ultra');
await hooks['command.execute.before']({ command: 'ponytail', arguments: 'status', sessionID: 's' });
assert.equal(fs.readFileSync(statePath, 'utf8'), 'ultra');
});
test('unrelated commands do not touch the flag', async () => {
try { fs.unlinkSync(statePath); } catch (e) {}
const hooks = await loadPlugin({});
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
test('root npm test covers bundled subprojects', () => {
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
assert.match(packageJson.scripts.test, /npm test --prefix pi-extension/);
assert.match(packageJson.scripts.test, /npm test --prefix ponytail-mcp/);
});
test('CI installs MCP dependencies before root npm test', () => {
const workflow = fs.readFileSync(path.join(root, '.github', 'workflows', 'test.yml'), 'utf8');
assert.match(workflow, /npm install --prefix ponytail-mcp/);
assert.ok(
workflow.indexOf('npm install --prefix ponytail-mcp') < workflow.indexOf('npm test'),
'MCP dependencies must be installed before the root test command runs',
);
});
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env node
// The README tells users to run `node scripts/uninstall.js`, so the npm package
// must actually ship it. Guard the files entry so it can't silently drop out.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
test('npm package ships the advertised cleanup script', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
assert.ok(
pkg.files.includes('scripts/uninstall.js'),
'package.json "files" must include scripts/uninstall.js (README tells users to run it)',
);
// And the file it points at must exist.
assert.ok(
fs.existsSync(path.join(root, 'scripts', 'uninstall.js')),
'scripts/uninstall.js is listed in files but missing on disk',
);
});
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
// Smoke test for the Qoder plugin adapter: verify manifest, rules, and skills
// wiring are present and consistent.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const SKILL_DIRS = [
'ponytail',
'ponytail-review',
'ponytail-audit',
'ponytail-debt',
'ponytail-gain',
'ponytail-help',
];
function readJSON(relPath) {
return JSON.parse(fs.readFileSync(path.join(root, relPath), 'utf8'));
}
test('qoder plugin manifest exists and has required fields', () => {
const manifest = readJSON('.qoder-plugin/plugin.json');
assert.equal(manifest.name, 'ponytail');
assert.ok(manifest.version, 'manifest must declare a version');
assert.ok(manifest.description, 'manifest must declare a description');
assert.ok(manifest.author, 'manifest must declare an author');
assert.equal(manifest.license, 'MIT');
assert.equal(manifest.skills, './skills/');
assert.equal(manifest.rules, './.qoder/rules/');
assert.equal(manifest.hooks, './hooks/qoder-hooks.json');
});
test('qoder hooks config exists and registers UserPromptSubmit', () => {
const hooksConfig = readJSON('hooks/qoder-hooks.json');
assert.ok(hooksConfig.hooks, 'hooks config must have a hooks key');
assert.ok(hooksConfig.hooks.UserPromptSubmit, 'must register UserPromptSubmit hook');
assert.ok(Array.isArray(hooksConfig.hooks.UserPromptSubmit), 'UserPromptSubmit must be an array');
const cmd = hooksConfig.hooks.UserPromptSubmit[0].hooks[0].command;
assert.ok(cmd.includes('ponytail-mode-tracker.js'), 'must point at ponytail-mode-tracker.js');
});
test('qoder rules file exists and is non-empty', () => {
const rulesPath = path.join(root, '.qoder', 'rules', 'ponytail.md');
assert.ok(fs.existsSync(rulesPath), '.qoder/rules/ponytail.md must exist');
const content = fs.readFileSync(rulesPath, 'utf8').trim();
assert.ok(content.length > 0, '.qoder/rules/ponytail.md must not be empty');
assert.ok(content.includes('lazy senior developer'), 'rules must contain the ponytail identity');
});
test('qoder manifest points at skills that actually ship', () => {
const manifest = readJSON('.qoder-plugin/plugin.json');
const skillsDir = path.join(root, manifest.skills);
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist');
for (const skill of SKILL_DIRS) {
const skillFile = path.join(skillsDir, skill, 'SKILL.md');
assert.ok(
fs.existsSync(skillFile),
`missing skill: skills/${skill}/SKILL.md`,
);
}
});
test('qoder rules match AGENTS.md canonical body', () => {
// Reuse the same logic as check-rule-copies.js: the .qoder copy must be
// byte-identical to AGENTS.md minus the repo-self-application paragraph.
const agents = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8')
.replace(/\r\n/g, '\n').trim();
const canonical = agents.replace(/\n\n\(Yes, this file also applies[\s\S]*?\)$/, '').trim();
const qoderCopy = fs.readFileSync(path.join(root, '.qoder', 'rules', 'ponytail.md'), 'utf8')
.replace(/\r\n/g, '\n').trim();
assert.equal(qoderCopy, canonical, '.qoder/rules/ponytail.md drifted from AGENTS.md');
});
test('qoder runtime detects QODER_SESSION_ID and writes hookSpecificOutput JSON', () => {
const { isQoder } = require('../hooks/ponytail-runtime');
// isQoder is resolved at module load time from process.env; in the test
// process QODER_SESSION_ID is unset, so isQoder must be false here.
// The positive path is exercised in hooks.test.js via spawnSync.
assert.equal(isQoder, false, 'isQoder must be false without QODER_SESSION_ID');
});
+38 -1
View File
@@ -69,8 +69,45 @@ assert.equal(
"a user's own statusLine must not be touched",
);
// #374: a combined statusline (another plugin && ponytail) must keep the other
// plugin's part — uninstall must not nuke the whole command or leave a husk.
fs.writeFileSync(settingsPath, JSON.stringify({
statusLine: { type: 'command', command: 'bash ~/caveman-statusline.sh && bash /p/ponytail-statusline.sh' },
}));
result = runUninstall(env);
assert.equal(result.status, 0, result.stderr);
const settingsAfter3 = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
assert.equal(
settingsAfter3.statusLine.command,
'bash ~/caveman-statusline.sh',
'a combined statusLine must keep the non-ponytail command',
);
// #434: a malformed settings.json must not crash the script mid-cleanup. It
// can't be safely edited, so uninstall warns and leaves the file byte-for-byte
// intact instead of throwing a SyntaxError after other state was already removed.
const malformedSettings = '{ "statusLine": { "command": "ponytail-statusline.sh", broken';
fs.writeFileSync(settingsPath, malformedSettings);
result = runUninstall(env);
assert.equal(
result.status,
0,
`expected exit 0 on malformed settings.json, got:\n${result.stdout}${result.stderr}`,
);
assert.ok(
/malformed/i.test(result.stdout + result.stderr),
'must warn that the statusLine entry could not be removed',
);
assert.equal(
fs.readFileSync(settingsPath, 'utf8'),
malformedSettings,
'malformed settings.json must be left unchanged',
);
// Running on an already-clean machine must not throw.
result = runUninstall({ HOME: path.join(temp, 'home-empty'), USERPROFILE: path.join(temp, 'home-empty') });
assert.equal(result.status, 0, result.stderr);
console.log('uninstall script checks passed');
console.log('uninstall script checks passed');