diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bc982fce..9a5d7c48 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -10,7 +10,7 @@ "repository": "https://github.com/DietrichGebert/ponytail", "license": "MIT", "keywords": ["yagni", "minimalism", "code-review", "productivity"], - "skills": "./skills/", + "skills": "./.codex-plugin/skills/", "hooks": "./hooks/claude-codex-hooks.json", "interface": { "displayName": "Ponytail", diff --git a/.codex-plugin/skills/ponytail-audit/SKILL.md b/.codex-plugin/skills/ponytail-audit/SKILL.md new file mode 100644 index 00000000..5582d103 --- /dev/null +++ b/.codex-plugin/skills/ponytail-audit/SKILL.md @@ -0,0 +1,41 @@ +--- +name: ponytail-audit +description: > + Whole-repo audit for over-engineering. Like ponytail-review, but scans the + entire codebase instead of a diff: a ranked list of what to delete, simplify, + or replace with stdlib/native equivalents. Use when the user says "audit this + codebase", "audit for over-engineering", "what can I delete from this repo", + "find bloat", "ponytail-audit", or "/ponytail-audit". One-shot report, does + not apply fixes. +--- + +ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank +findings biggest cut first. + +## Tags + +Same as ponytail-review: + +- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing. +- `stdlib:` hand-rolled thing the standard library ships. Name the function. +- `native:` dependency or code doing what the platform already does. Name the feature. +- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller. +- `shrink:` same logic, fewer lines. Show the shorter form. + +## Hunt + +Deps the stdlib or platform already ships, single-implementation interfaces, +factories with one product, wrappers that only delegate, files exporting one +thing, dead flags and config, hand-rolled stdlib. + +## Output + +One line per finding, ranked: ` . . [path]`. +End with `net: - lines, - deps possible.` Nothing to cut: `Lean already. Ship.` + +## Boundaries + +Scope: over-engineering and complexity only. Correctness bugs, security holes, +and performance are explicitly out of scope. Route them to a normal review +pass. Lists findings, applies nothing. One-shot. +"stop ponytail-audit" or "normal mode" to revert. diff --git a/.codex-plugin/skills/ponytail-audit/agents/openai.yaml b/.codex-plugin/skills/ponytail-audit/agents/openai.yaml new file mode 100644 index 00000000..c6dd65e6 --- /dev/null +++ b/.codex-plugin/skills/ponytail-audit/agents/openai.yaml @@ -0,0 +1,3 @@ +policy: + products: + - codex diff --git a/.codex-plugin/skills/ponytail-debt/SKILL.md b/.codex-plugin/skills/ponytail-debt/SKILL.md new file mode 100644 index 00000000..ecbc0ca8 --- /dev/null +++ b/.codex-plugin/skills/ponytail-debt/SKILL.md @@ -0,0 +1,44 @@ +--- +name: ponytail-debt +description: > + Harvest every `ponytail:` comment in the codebase into a debt ledger, so the + deliberate shortcuts and deferrals ponytail leaves behind get tracked instead + of rotting into "later means never". Use when the user says "ponytail debt", + "/ponytail-debt", "what did ponytail defer", "list the shortcuts", "ponytail + ledger", or "what did we mark to do later". One-shot report, changes nothing. +--- + +Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming +its ceiling and upgrade path. This collects them into one ledger so a deferral +can't quietly become permanent. + +## Scan + +Grep the repo for comment markers, skipping `node_modules`, `.git`, and build +output: + +`grep -rnE '(#|//) ?ponytail:' .` (add other comment prefixes if your stack uses them) + +Each hit is one ledger row. The comment prefix keeps prose that merely mentions +the convention out of the ledger. + +## Output + +One row per marker, grouped by file: + +`:, . ceiling: . upgrade: .` + +The convention is `ponytail: , `, so pull the ceiling +and the trigger straight from the comment. Want an owner per row too? add +`git blame -L,`. + +Flag the rot risk: any `ponytail:` comment that names no upgrade path or +trigger gets a `no-trigger` tag, those are the ones that silently rot. + +End with ` markers, with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.` + +## Boundaries + +Reads and reports only, changes nothing. To persist it, ask and it writes the +ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or +"normal mode" to revert. diff --git a/.codex-plugin/skills/ponytail-debt/agents/openai.yaml b/.codex-plugin/skills/ponytail-debt/agents/openai.yaml new file mode 100644 index 00000000..c6dd65e6 --- /dev/null +++ b/.codex-plugin/skills/ponytail-debt/agents/openai.yaml @@ -0,0 +1,3 @@ +policy: + products: + - codex diff --git a/.codex-plugin/skills/ponytail-gain/SKILL.md b/.codex-plugin/skills/ponytail-gain/SKILL.md new file mode 100644 index 00000000..012e37b6 --- /dev/null +++ b/.codex-plugin/skills/ponytail-gain/SKILL.md @@ -0,0 +1,50 @@ +--- +name: ponytail-gain +description: > + Show ponytail's measured impact as a compact scoreboard: less code, less + cost, more speed, from the benchmark medians. One-shot display, not a + persistent mode, and not a per-repo number. Trigger: /ponytail-gain, + "ponytail gain", "what does ponytail save", "show ponytail impact", + "ponytail scoreboard". +--- + +# Ponytail Gain + +Display this scoreboard when invoked. One-shot: do NOT change mode, write flag +files, or persist anything. + +The figures are the published benchmark medians (5 everyday tasks: email +validator, debounce, CSV sum, countdown timer, rate limiter; three models: +Haiku, Sonnet, Opus). They are measured, not computed from the current repo. +Source: `benchmarks/` and the README. + +## Scoreboard + +Render plain ASCII bars. The bar length shows the measured range; the label +carries the exact figure: + +``` + ponytail gain benchmark median · 5 tasks · 3 models + + Lines of code no-skill ████████████████████ 100% + ponytail ██▌················· 6–20% ▼ 80–94% + Cost no-skill ████████████████████ 100% + ponytail █████▌·············· 23–53% ▼ 47–77% + Speed ponytail ▸ 3–6× faster + + This repo: /ponytail-debt (shortcuts you deferred) + /ponytail-audit (what's still cuttable) +``` + +## Honesty boundary + +These are benchmark medians, not this repo. NEVER print a per-repo savings +number ("you saved X lines/tokens here"): the unbuilt version was never +written, so there is no real baseline to subtract from in a live repo. The +only real per-repo figures come from `/ponytail-debt` (a counted ledger), and +this card points there instead of inventing one. + +## Boundaries + +One-shot display. Edits nothing, changes no mode. +"stop ponytail" or "normal mode": revert. diff --git a/.codex-plugin/skills/ponytail-gain/agents/openai.yaml b/.codex-plugin/skills/ponytail-gain/agents/openai.yaml new file mode 100644 index 00000000..c6dd65e6 --- /dev/null +++ b/.codex-plugin/skills/ponytail-gain/agents/openai.yaml @@ -0,0 +1,3 @@ +policy: + products: + - codex diff --git a/.codex-plugin/skills/ponytail-help/SKILL.md b/.codex-plugin/skills/ponytail-help/SKILL.md new file mode 100644 index 00000000..d669343c --- /dev/null +++ b/.codex-plugin/skills/ponytail-help/SKILL.md @@ -0,0 +1,74 @@ +--- +name: ponytail-help +description: > + Quick-reference card for all ponytail modes, skills, and commands. + One-shot display, not a persistent mode. Trigger: /ponytail-help, + "ponytail help", "what ponytail commands", "how do I use ponytail". +--- + +# Ponytail Help + +Display this reference card when invoked. One-shot, do NOT change mode, +write flag files, or persist anything. + +## Levels + +| Level | Trigger | What change | +|-------|---------|-------------| +| **Lite** | `/ponytail lite` | Build what's asked, name the lazier alternative in one line. | +| **Full** | `/ponytail` | The ladder enforced: YAGNI → stdlib → native → one line → minimum. Default. | +| **Ultra** | `/ponytail ultra` | YAGNI extremist. Deletion before addition. Challenges requirements before building. | + +Level sticks until changed or session end. + +## Skills + +| Skill | Trigger | What it does | +|-------|---------|--------------| +| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. | +| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` | +| **ponytail-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 mode switching accepts the convenience hook form `$ponytail full` and +the explicit controller form `$ponytail:ponytail full`. +Codex one-shot skills use qualified names such as +`$ponytail:ponytail-review` and `$ponytail:ponytail-help`. +Claude Code and OpenCode use the slash-command forms above (OpenCode ships all +six as slash commands). + +## Deactivate + +Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. +`/ponytail off` also works. + +## Configure Default Mode + +Default mode = `full`, auto-active every session. Change it: + +**Environment variable** (highest priority): +```bash +export PONYTAIL_DEFAULT_MODE=ultra +``` + +**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`): +```json +{ "defaultMode": "lite" } +``` + +Set `"off"` to disable auto-activation on session start, activate manually +with `/ponytail` when wanted. + +Resolution: env var > config file > `full`. + +## Update + +Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`. + +If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow. + +## More + +Full docs + examples: https://github.com/DietrichGebert/ponytail diff --git a/.codex-plugin/skills/ponytail-help/agents/openai.yaml b/.codex-plugin/skills/ponytail-help/agents/openai.yaml new file mode 100644 index 00000000..c6dd65e6 --- /dev/null +++ b/.codex-plugin/skills/ponytail-help/agents/openai.yaml @@ -0,0 +1,3 @@ +policy: + products: + - codex diff --git a/.codex-plugin/skills/ponytail-review/SKILL.md b/.codex-plugin/skills/ponytail-review/SKILL.md new file mode 100644 index 00000000..e137a855 --- /dev/null +++ b/.codex-plugin/skills/ponytail-review/SKILL.md @@ -0,0 +1,57 @@ +--- +name: ponytail-review +description: > + Code review focused exclusively on over-engineering. Finds what to delete: + reinvented standard library, unneeded dependencies, speculative abstractions, + dead flexibility. One line per finding: location, what to cut, what replaces + it. Use when the user says "review for over-engineering", "what can we + delete", "is this over-engineered", "simplify review", or invokes + /ponytail-review. Complements correctness-focused review, this one only + hunts complexity. +--- + +Review diffs for unnecessary complexity. One line per finding: location, what +to cut, what replaces it. The diff's best outcome is getting shorter. + +## Format + +`L: . .`, or `:L: ...` for +multi-file diffs. + +Tags: + +- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing. +- `stdlib:` hand-rolled thing the standard library ships. Name the function. +- `native:` dependency or code doing what the platform already does. Name the feature. +- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller. +- `shrink:` same logic, fewer lines. Show the shorter form. + +## Examples + +❌ "This EmailValidator class might be more complex than necessary, have you +considered whether all these validation rules are needed at this stage?" + +✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.` + +✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.` + +✅ `repo.py:L88: yagni: AbstractRepository with one implementation. Inline it until a second one exists.` + +✅ `L52-71: delete: retry wrapper around an idempotent local call. Nothing replaces it.` + +✅ `L30-44: shrink: manual loop builds dict. dict(zip(keys, values)), 1 line.` + +## Scoring + +End with the only metric that matters: `net: - lines possible.` + +If there is nothing to cut, say `Lean already. Ship.` and stop. + +## Boundaries + +Scope: over-engineering and complexity only. Correctness bugs, security holes, +and performance are explicitly out of scope. Route them to a normal review +pass, not this one. A single smoke test or `assert`-based +self-check is the ponytail minimum, not bloat, never flag it for deletion. +Does not apply the fixes, only lists them. +"stop ponytail-review" or "normal mode": revert to verbose review style. diff --git a/.codex-plugin/skills/ponytail-review/agents/openai.yaml b/.codex-plugin/skills/ponytail-review/agents/openai.yaml new file mode 100644 index 00000000..c6dd65e6 --- /dev/null +++ b/.codex-plugin/skills/ponytail-review/agents/openai.yaml @@ -0,0 +1,3 @@ +policy: + products: + - codex diff --git a/.codex-plugin/skills/ponytail/SKILL.md b/.codex-plugin/skills/ponytail/SKILL.md new file mode 100644 index 00000000..81c86bcb --- /dev/null +++ b/.codex-plugin/skills/ponytail/SKILL.md @@ -0,0 +1,25 @@ +--- +name: ponytail +description: > + Explicitly control Ponytail mode for the current Codex thread. + Use when the user invokes $ponytail to inspect, enable, change, or disable + lite, full, or ultra mode, or to configure the default for future threads. +argument-hint: "[lite|full|ultra|off|default ]" +license: MIT +--- + +# Ponytail Controller + +This Codex-only skill controls Ponytail's persistent mode for the current thread. +The lifecycle hook performs the transition and supplies the authoritative instructions for the selected mode. +Do not restate or emulate the Ponytail ruleset from this controller. + +## Commands + +- `$ponytail` reports the current mode without changing it. +- `$ponytail lite`, `$ponytail full`, and `$ponytail ultra` select a mode for the current thread. +- `$ponytail off` disables Ponytail for the current thread. +- `$ponytail default ` changes only the default for future threads. + +After a mode command, follow the newest `PONYTAIL CONTROL` supplied by the lifecycle hook. +The one-shot review, audit, debt, gain, and help skills do not change the persistent mode. diff --git a/.codex-plugin/skills/ponytail/agents/openai.yaml b/.codex-plugin/skills/ponytail/agents/openai.yaml new file mode 100644 index 00000000..d42a3cc7 --- /dev/null +++ b/.codex-plugin/skills/ponytail/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + allow_implicit_invocation: false + products: + - codex diff --git a/.gitignore b/.gitignore index 7c818a20..3e95ee15 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,8 @@ ponytail-*.gif # Claude Code local settings (machine-specific permission grants) .claude/settings.local.json +# Codex local marketplace generated by scripts/build-codex-local.js +.codex-local-marketplace/ + # agentic benchmark workspaces (agent output, kept locally for inspection) benchmarks/agentic/runs/ diff --git a/.openclaw/skills/ponytail-help/SKILL.md b/.openclaw/skills/ponytail-help/SKILL.md index 50ff0f1e..c1405a89 100644 --- a/.openclaw/skills/ponytail-help/SKILL.md +++ b/.openclaw/skills/ponytail-help/SKILL.md @@ -31,9 +31,12 @@ Level sticks until changed or session end. | **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 all six as -slash commands). +Codex mode switching accepts the convenience hook form `$ponytail full` and +the explicit controller form `$ponytail:ponytail full`. +Codex one-shot skills use qualified names such as +`$ponytail:ponytail-review` and `$ponytail:ponytail-help`. +Claude Code and OpenCode use the slash-command forms above (OpenCode ships all +six as slash commands). ## Deactivate diff --git a/README.md b/README.md index c2e9b0e2..8ed0fe04 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,8 @@ Lazy, not negligent: trust-boundary validation, data-loss handling, security, an The most effort ponytail will ever ask of you: -The Claude Code and Codex plugins run two tiny Node.js lifecycle hooks, so `node` needs to be on your PATH (note for Nix/nvm users: it must be on the non-interactive shell's PATH). If it isn't, the skills still work, the always-on activation just stays quiet instead of erroring on every prompt. +The Claude Code and Codex plugins run three tiny Node.js lifecycle hooks, so `node` needs to be on your PATH (note for Nix/nvm users: it must be on the non-interactive shell's PATH). +Without Node, hosts can still load standalone skill bodies, but automatic lifecycle activation and Codex mode switching do not work. ### Claude Code @@ -130,10 +131,30 @@ codex plugin marketplace add DietrichGebert/ponytail codex plugin add ponytail@ponytail ``` -Run `codex` and open `/hooks`, review and trust its two lifecycle hooks, and start a new thread. +Run `codex` and open `/hooks`, review and trust its three 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. +The Codex install is global, but activation is isolated per root thread and shared only with that thread's subagents. +To keep Ponytail dormant for research and brainstorming, set future threads to opt in: + +```text +$ponytail default off +``` + +Start a new thread, then enable it only when planning or implementing software: + +```text +$ponytail lite +$ponytail full +$ponytail ultra +$ponytail off +``` + +`$ponytail ...` is the convenience hook syntax; the explicit generated controller also accepts `$ponytail:ponytail ...`. +With the default set to `off`, Codex receives no Ponytail rules at startup. +The first active switch injects the common rules once, and later switches add only a small authoritative mode control instead of duplicating the ruleset. + ### GitHub Copilot CLI ```bash @@ -248,10 +269,17 @@ Installs ponytail as an OpenClaw skill from ClawHub; the review, audit, debt, ga 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. +Ponytail starts at the configured default and provides 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`. +On Codex, a mode belongs to the root thread, so two concurrent threads can use different modes safely. +Resume preserves valid thread state without reinjecting the base, compact preserves the active mode and injects one fresh base for the new context, and clear returns the thread to its configured default. +Turning Ponytail off adds an authoritative off control; turning it back on in the same context reuses the base already present. +Codex currently does not dispatch the compact reinjection lifecycle to a long-lived child subagent, so a child that compacts its own context may lose Ponytail until it ends; the parent thread and newly spawned subagents are unaffected. + 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/)). @@ -280,7 +308,11 @@ Which files map to which agent: [Agent portability](docs/agent-portability.md). | Pi agent | `pi uninstall ponytail` | | 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. +These remove the plugin's own files. +They can leave behind a small amount of state Ponytail writes outside the plugin folder: the legacy 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. ## Commands @@ -293,7 +325,12 @@ 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, Devin CLI, OpenCode, Gemini, pi, Swival, Hermes Agent, Qoder). 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). +In Codex, `$ponytail full` is the convenience hook syntax and `$ponytail:ponytail full` invokes the generated controller explicitly. +Plugin one-shot skills use qualified names such as `$ponytail:ponytail-review`. +The Codex plugin uses a generated lightweight controller; the portable full rules skill is excluded so a mode switch does not duplicate the rules already injected by the hook. +The five report/help skills are one-shot and never change the persistent Ponytail mode. +The instruction-only adapters (Cursor, Windsurf, Cline, Copilot, Kiro, Antigravity) load the always-on ruleset without the commands. ## Development @@ -304,7 +341,10 @@ node scripts/check-rule-copies.js npm test ``` -The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale. To publish the skills to ClawHub, run `clawhub login` once, then `node scripts/publish-openclaw-skills.js` (it publishes all six at the `package.json` version; pass `--dry-run` to preview). +The Codex skill package (`.codex-plugin/skills/`) is generated from `skills/`; rerun `node scripts/build-codex-skills.js` after changing a skill. +To test this checkout as the globally installed Codex plugin, run `npm run codex:local`. +The OpenClaw skill package (`.openclaw/skills/`) is generated from `skills/`; rerun `node scripts/build-openclaw-skills.js` after changing a skill, the test suite fails if it is stale. +To publish the skills to ClawHub, run `clawhub login` once, then `node scripts/publish-openclaw-skills.js` (it publishes all six at the `package.json` version; pass `--dry-run` to preview). The correctness benchmark spawns Python for email and CSV checks; `python3` is tried before `python`. CSV checks need `pandas` installed locally. diff --git a/docs/agent-portability.md b/docs/agent-portability.md index 2e15e133..a11d8f6f 100644 --- a/docs/agent-portability.md +++ b/docs/agent-portability.md @@ -9,7 +9,7 @@ to load in a given agent. | Host | Files | Notes | |------|-------|-------| | Claude Code | `.claude-plugin/plugin.json`, `commands/`, `hooks/claude-codex-hooks.json`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. | -| Codex | `.codex-plugin/plugin.json`, `hooks/claude-codex-hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. | +| Codex | `.codex-plugin/plugin.json`, `.codex-plugin/skills/`, `hooks/claude-codex-hooks.json`, `hooks/` | Plugin install with a generated explicit controller plus per-session lifecycle hooks; the portable full `skills/ponytail` copy is excluded from Codex to avoid duplicate rules. | | 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:`. | @@ -31,6 +31,29 @@ to load in a given agent. | 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. | +## Codex lifecycle + +Codex installs Ponytail globally, but hook state is keyed by root `session_id`, which Codex shares with that root thread's subagents. +Concurrent root threads can therefore use different modes without overwriting each other. + +Set `$ponytail default off` to make new Codex threads opt in, then use `$ponytail lite`, `$ponytail full`, `$ponytail ultra`, or `$ponytail off` inside a thread. +These unqualified forms are convenience hook commands; `$ponytail:ponytail ...` is the namespaced explicit controller form. +An off startup injects no rules. +The first active switch injects one mode-neutral base plus an authoritative mode control, while later mode changes inject only a replacement control. +Repeating the current mode is a no-op. + +Resume reuses valid persisted thread state without duplicating the base. +Compact preserves the current mode and injects one fresh base into the replacement context. +Clear resets the thread to its configured default. +New subagents receive a complete activation when the parent thread is active, and receive nothing when it is off. + +The generated `.codex-plugin/skills/` tree is product-gated to Codex, and its `ponytail` controller is explicit-only and contains no ruleset body. +The canonical `skills/` tree remains the portable source for other hosts, while Codex uses the generated controller and generated one-shot skill copies. +`$ponytail:ponytail-review`, `$ponytail:ponytail-audit`, `$ponytail:ponytail-debt`, `$ponytail:ponytail-gain`, and `$ponytail:ponytail-help` are one-shot and never mutate the persistent mode. + +Codex currently does not dispatch compact reinjection for a long-lived child subagent's own context. +If such a child compacts, it may lose Ponytail until it ends; the parent state and newly spawned children remain correct. + ## Adapter Rule Keep adapters thin. When a host supports skills or hooks, point it at the diff --git a/hooks/ponytail-activate.js b/hooks/ponytail-activate.js index d54fbe4a..f3afa5f0 100644 --- a/hooks/ponytail-activate.js +++ b/hooks/ponytail-activate.js @@ -1,15 +1,17 @@ #!/usr/bin/env node -// ponytail — Claude Code SessionStart activation hook +// ponytail — session-start activation hook // // Runs on every session start: -// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.ponytail-active (defaults to ~/.claude; statusline reads this) -// 2. Emits ponytail ruleset as hidden SessionStart context -// 3. Detects missing statusline config and emits setup nudge +// 1. Stores host-appropriate session state +// 2. Emits Ponytail instructions as hidden SessionStart context +// 3. On legacy hosts, detects missing statusline config and emits a setup nudge const fs = require('fs'); const path = require('path'); const { getDefaultMode, getClaudeDir, isShellSafe } = require('./ponytail-config'); const { getPonytailInstructions } = require('./ponytail-instructions'); +const { startSession } = require('./ponytail-codex-session'); +const { readHookPayload } = require('./ponytail-stdin'); const { clearMode, isCodex, @@ -21,76 +23,90 @@ const { const claudeDir = getClaudeDir(); const settingsPath = path.join(claudeDir, 'settings.json'); -const mode = getDefaultMode(); - -// "off" mode — skip activation entirely, don't write flag or emit rules -if (mode === 'off') { - clearMode(); - const hookOutput = (isCodex || isCopilot) ? '' : 'OK'; - writeHookOutput('SessionStart', 'off', hookOutput); - process.exit(0); +if (isCodex) { + readHookPayload((payload) => { + if (!payload) return; + const result = startSession(payload.session_id, payload.source, getDefaultMode()); + if (!result) return; + try { + writeHookOutput('SessionStart', result.mode, result.context); + } catch (_) {} + }); +} else { + activateLegacy(); } -// 1. Write flag file -try { - setMode(mode); -} catch (e) { - // Silent fail -- flag is best-effort, don't block the hook -} +function activateLegacy() { + const mode = getDefaultMode(); -// 2. Emit the ponytail ruleset, filtered to the active intensity level. -let output = getPonytailInstructions(mode); + // "off" mode — skip activation entirely, don't write flag or emit rules + if (mode === 'off') { + clearMode(); + writeHookOutput('SessionStart', 'off', isCopilot ? '' : 'OK'); + return; + } -// 3. Detect missing statusline config — nudge Claude to help set it up -if (!isCodex && !isCopilot) try { - let hasStatusline = false; - if (fs.existsSync(settingsPath)) { - // Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse) - const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); - const settings = JSON.parse(raw); - if (settings.statusLine) { - hasStatusline = true; - } + // 1. Write flag file + try { + setMode(mode); + } catch (e) { + // Silent fail -- flag is best-effort, don't block the hook } - // 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); - if (isShellSafe(scriptPath)) { - const command = isWindows - ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` - : `bash "${scriptPath}"`; - const statusLineSnippet = - '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; - 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 " + settingsPath + ": " + - statusLineSnippet + " " + - "Proactively offer to set this up for the user on first interaction."; - } else { - // ponytail: install path has shell metacharacters — don't embed it in a - // command snippet; have the agent wire it up by hand instead. - output += "\n\n" + - "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 " + settingsPath + ", quoting/escaping the path for your shell. " + - "Proactively offer to set this up for the user on first interaction."; + // 2. Emit the ponytail ruleset, filtered to the active intensity level. + let output = getPonytailInstructions(mode); + + // 3. Detect missing statusline config — nudge Claude to help set it up + if (!isCopilot) try { + let hasStatusline = false; + if (fs.existsSync(settingsPath)) { + // Strip UTF-8 BOM some editors prepend on Windows (breaks JSON.parse) + const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); + const settings = JSON.parse(raw); + if (settings.statusLine) { + hasStatusline = true; + } } + + // 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); + if (isShellSafe(scriptPath)) { + const command = isWindows + ? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"` + : `bash "${scriptPath}"`; + const statusLineSnippet = + '"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }'; + 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 " + settingsPath + ": " + + statusLineSnippet + " " + + "Proactively offer to set this up for the user on first interaction."; + } else { + // ponytail: install path has shell metacharacters — don't embed it in a + // command snippet; have the agent wire it up by hand instead. + output += "\n\n" + + "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 " + settingsPath + ", quoting/escaping the path for your shell. " + + "Proactively offer to set this up for the user on first interaction."; + } + } + } catch (e) { + // Silent fail — don't block session start over statusline detection } -} catch (e) { - // Silent fail — don't block session start over statusline detection -} -try { - writeHookOutput('SessionStart', mode, output); -} catch (e) { - // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure + try { + writeHookOutput('SessionStart', mode, output); + } catch (e) { + // Silent fail — stdout closed/EPIPE at hook exit must not surface as a hook failure + } } diff --git a/hooks/ponytail-codex-session.js b/hooks/ponytail-codex-session.js new file mode 100644 index 00000000..84940afc --- /dev/null +++ b/hooks/ponytail-codex-session.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { normalizeMode } = require('./ponytail-config'); +const { + getCodexActivation, + getCodexBaseHash, + getCodexControlInstructions, +} = require('./ponytail-instructions'); + +const STATE_VERSION = 1; +const HASH_RE = /^[a-f0-9]{64}$/; + +function normalizeSessionId(sessionId) { + if (typeof sessionId !== 'string') return null; + const normalized = sessionId.trim(); + return normalized && normalized.length <= 4096 ? normalized : null; +} + +function getSessionStatePath(sessionId) { + const normalized = normalizeSessionId(sessionId); + const dataDir = process.env.PLUGIN_DATA; + if (!normalized || !dataDir) return null; + const digest = crypto.createHash('sha256').update(normalized).digest('hex'); + return path.join(dataDir, 'sessions', digest + '.json'); +} + +function normalizeState(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const mode = normalizeMode(value.mode); + const baseHash = value.baseHash === null || value.baseHash === undefined + ? null + : typeof value.baseHash === 'string' ? value.baseHash.toLowerCase() : false; + if ( + value.version !== STATE_VERSION || + !mode || + !Number.isSafeInteger(value.generation) || value.generation < 1 || + !Number.isSafeInteger(value.revision) || value.revision < 0 || + (baseHash !== null && !HASH_RE.test(baseHash)) + ) return null; + return { + version: STATE_VERSION, + mode, + generation: value.generation, + revision: value.revision, + baseHash, + }; +} + +function createState(mode, generation = 1, revision = 0) { + return { + version: STATE_VERSION, + mode: normalizeMode(mode) || 'off', + generation, + revision, + baseHash: null, + }; +} + +function createRecoveryState(mode) { + // ponytail: a wall-clock epoch outranks normal counters but not clock rollback + // or forged future generations; persist a monotonic epoch outside session state + // if either becomes a real failure mode. + return createState(mode, Date.now()); +} + +function readSessionState(sessionId) { + const statePath = getSessionStatePath(sessionId); + if (!statePath) return null; + try { + return normalizeState(JSON.parse(fs.readFileSync(statePath, 'utf8').replace(/^\uFEFF/, ''))); + } catch (_) { + return null; + } +} + +function writeSessionState(sessionId, state) { + const statePath = getSessionStatePath(sessionId); + const normalized = normalizeState(state); + if (!statePath || !normalized) return false; + + let temporaryPath; + try { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + temporaryPath = statePath + '.' + process.pid + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'; + fs.writeFileSync(temporaryPath, JSON.stringify(normalized), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(temporaryPath, statePath); + return true; + } catch (_) { + if (temporaryPath) try { fs.unlinkSync(temporaryPath); } catch (_) {} + return false; + } +} + +function activateState(state) { + const baseHash = getCodexBaseHash(); + state.baseHash = baseHash; + return getCodexActivation(state.mode, state.generation, state.revision); +} + +function startSession(sessionId, source, defaultMode) { + if (!getSessionStatePath(sessionId)) return null; + + const configuredMode = normalizeMode(defaultMode) || 'off'; + const previous = readSessionState(sessionId); + let state; + let context = ''; + let changed = true; + + switch (source) { + case 'resume': + if (!previous) { + state = createRecoveryState(configuredMode); + context = state.mode === 'off' + ? getCodexControlInstructions('off', state.generation, state.revision) + : activateState(state); + } else { + state = previous; + if (state.mode !== 'off' && state.baseHash !== getCodexBaseHash()) { + context = activateState(state); + } else changed = false; + } + break; + case 'clear': + state = createState(configuredMode, previous ? previous.generation + 1 : 1); + if (state.mode !== 'off') context = activateState(state); + break; + case 'compact': + state = previous || createState(configuredMode); + if (previous) state.generation += 1; + state.baseHash = null; + if (state.mode !== 'off') context = activateState(state); + break; + case 'startup': + default: + state = createState(configuredMode); + if (state.mode !== 'off') context = activateState(state); + break; + } + + if (changed && !writeSessionState(sessionId, state)) return null; + return { context, mode: state.mode, state }; +} + +function transitionSessionMode(sessionId, requestedMode, defaultMode) { + if (!getSessionStatePath(sessionId)) return null; + const targetMode = normalizeMode(requestedMode); + if (!targetMode) return null; + + const previous = readSessionState(sessionId); + const current = previous || createRecoveryState(defaultMode); + let context = ''; + let changed = !previous; + + if (!previous && targetMode === 'off') { + context = getCodexControlInstructions('off', current.generation, current.revision); + } else if (current.mode !== targetMode) { + changed = true; + current.mode = targetMode; + current.revision += 1; + if (targetMode === 'off') { + context = getCodexControlInstructions('off', current.generation, current.revision); + } else if (current.baseHash === getCodexBaseHash()) { + context = getCodexControlInstructions(targetMode, current.generation, current.revision); + } else { + context = activateState(current); + } + } else if (targetMode !== 'off' && current.baseHash !== getCodexBaseHash()) { + changed = true; + context = activateState(current); + } + + if (changed && !writeSessionState(sessionId, current)) return null; + return { context, mode: current.mode, state: current }; +} + +function repairSession(sessionId, defaultMode, options = {}) { + if (!getSessionStatePath(sessionId)) return null; + let state = readSessionState(sessionId); + let context = ''; + let changed = false; + + if (!state) { + if (options.initialize === false) return null; + state = createRecoveryState(defaultMode); + changed = true; + context = state.mode === 'off' + ? getCodexControlInstructions('off', state.generation, state.revision) + : activateState(state); + } else if (state.mode !== 'off' && state.baseHash !== getCodexBaseHash()) { + changed = true; + context = activateState(state); + } + + if (changed && !writeSessionState(sessionId, state)) return null; + return { context, mode: state.mode, state }; +} + +function getSubagentActivation(sessionId) { + const state = readSessionState(sessionId); + if (!state || state.mode === 'off') return null; + return { + context: getCodexActivation(state.mode, state.generation, state.revision), + mode: state.mode, + state, + }; +} + +module.exports = { + STATE_VERSION, + createState, + getSessionStatePath, + getSubagentActivation, + normalizeSessionId, + readSessionState, + repairSession, + startSession, + transitionSessionMode, + writeSessionState, +}; diff --git a/hooks/ponytail-instructions.js b/hooks/ponytail-instructions.js index 3ec3980a..bfd1b740 100644 --- a/hooks/ponytail-instructions.js +++ b/hooks/ponytail-instructions.js @@ -3,6 +3,7 @@ const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config'); const INDEPENDENT_MODES = new Set(['review']); @@ -91,8 +92,82 @@ function getPonytailInstructions(mode) { } } +function canonicalSkillBody() { + return fs.readFileSync(SKILL_PATH, 'utf8').replace(/^---[\s\S]*?---\s*/, ''); +} + +function withoutSections(body, sectionNames) { + const names = new Set(sectionNames.map((name) => name.toLowerCase())); + const output = []; + let skipping = false; + + for (const line of body.split(/\r?\n/)) { + const heading = line.match(/^##\s+(.+?)\s*$/); + if (heading) skipping = names.has(heading[1].toLowerCase()); + if (!skipping) output.push(line); + } + return output.join('\n').trim(); +} + +function getCodexBaseContent() { + const commonRules = withoutSections(canonicalSkillBody(), ['Persistence', 'Intensity']); + return 'This base supersedes every earlier PONYTAIL BASE. Apply it only when the latest PONYTAIL CONTROL names this base hash.\n\n' + + commonRules; +} + +function getCodexBaseHash() { + return crypto.createHash('sha256').update(getCodexBaseContent()).digest('hex'); +} + +function getCodexBaseInstructions() { + return 'PONYTAIL BASE hash=' + getCodexBaseHash() + '\n' + getCodexBaseContent(); +} + +function getModeDetails(mode) { + const body = canonicalSkillBody(); + const tableRow = body.split(/\r?\n/).find((line) => { + const match = line.match(/^\|\s*\*\*(lite|full|ultra)\*\*\s*\|/i); + return match && match[1].toLowerCase() === mode; + }); + const example = body.split(/\r?\n/).find((line) => { + const match = line.match(/^-\s*(lite|full|ultra):\s*"/i); + return match && match[1].toLowerCase() === mode; + }); + const semantics = tableRow + ? tableRow.replace(/^\|\s*\*\*[^*]+\*\*\s*\|\s*/, '').replace(/\s*\|\s*$/, '') + : 'Apply the canonical Ponytail rules at ' + mode + ' intensity.'; + return { example: example || '', semantics }; +} + +function getCodexControlInstructions(mode, generation, revision) { + const effectiveMode = normalizeMode(mode) || 'off'; + const baseHash = effectiveMode === 'off' ? 'none' : getCodexBaseHash(); + const header = 'PONYTAIL CONTROL generation=' + generation + ' revision=' + revision + + ' mode=' + effectiveMode + ' base=' + baseHash + '\n' + + 'This control supersedes every earlier PONYTAIL CONTROL; if controls share generation/revision, the later control wins.'; + + if (effectiveMode === 'off') { + return header + '\nPonytail is off. Suspend PONYTAIL BASE and every earlier control until a newer active control appears.'; + } + + const details = getModeDetails(effectiveMode); + return header + '\nPonytail is active at **' + effectiveMode + '** intensity.\n' + + 'Complete mode semantics: ' + details.semantics + + (details.example ? '\nCanonical example:\n' + details.example : ''); +} + +function getCodexActivation(mode, generation, revision) { + return getCodexBaseInstructions() + '\n\n' + + getCodexControlInstructions(mode, generation, revision); +} + module.exports = { filterSkillBodyForMode, + getCodexActivation, + getCodexBaseContent, + getCodexBaseHash, + getCodexBaseInstructions, + getCodexControlInstructions, getFallbackInstructions, getPonytailInstructions, }; diff --git a/hooks/ponytail-mode-tracker.js b/hooks/ponytail-mode-tracker.js index f644f00a..ed18a11e 100644 --- a/hooks/ponytail-mode-tracker.js +++ b/hooks/ponytail-mode-tracker.js @@ -1,130 +1,204 @@ #!/usr/bin/env node // 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, writeDefaultMode } = require('./ponytail-config'); -const { clearMode, isQoder, readMode, setMode, writeHookOutput } = require('./ponytail-runtime'); +const { + normalizeSessionId, + readSessionState, + repairSession, + transitionSessionMode, +} = require('./ponytail-codex-session'); const { getPonytailInstructions } = require('./ponytail-instructions'); +const { readHookPayload } = require('./ponytail-stdin'); +const { + clearMode, + isCodex, + isQoder, + readMode, + setMode, + writeHookOutput, +} = require('./ponytail-runtime'); -let input = ''; -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 ` 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 if (arg === '') { - isReportOnly = true; - mode = readMode() || getDefaultMode(); - } else { - mode = getDefaultMode(); - } - } +readHookPayload((data) => { + if (!data) return; + const prompt = String(data.prompt || '').trim().toLowerCase(); + if (isCodex) handleCodex(data, prompt); + else handleLegacy(prompt); +}); - if (isReportOnly) { +function normalizedCommand(prompt) { + const parts = prompt.split(/\s+/); + return { + arg: parts[1] || '', + command: parts[0].replace(/^[@$]/, '/'), + parts, + }; +} + +function isControllerCommand(command) { + return command === '/ponytail' || command === '/ponytail:ponytail'; +} + +function isOneShotCommand(command) { + return command === '/ponytail-review' || + command === '/ponytail:ponytail-review' || + command === '/ponytail-audit' || + command === '/ponytail:ponytail-audit' || + command === '/ponytail-debt' || + command === '/ponytail:ponytail-debt' || + command === '/ponytail-gain' || + command === '/ponytail:ponytail-gain' || + command === '/ponytail-help' || + command === '/ponytail:ponytail-help'; +} + +function emitRepair(sessionId, options) { + const result = repairSession(sessionId, getDefaultMode(), options); + if (result && result.context) { + writeHookOutput('UserPromptSubmit', result.mode, result.context); + } + return result; +} + +function handleCodex(data, prompt) { + if (!normalizeSessionId(data.session_id)) return; + const parsed = normalizedCommand(prompt); + + if (isControllerCommand(parsed.command)) { + if (parsed.arg === 'default') { + const currentMode = readSessionState(data.session_id)?.mode || getDefaultMode(); + const configured = writeDefaultMode(parsed.parts[2]); + if (configured) { writeHookOutput( 'UserPromptSubmit', - mode, - 'PONYTAIL MODE ACTIVE — level: ' + mode, + currentMode, + 'PONYTAIL DEFAULT SET — new sessions start in ' + configured + '.', ); - } 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, - ); + } + return; + } + + if (['off', 'lite', 'full', 'ultra'].includes(parsed.arg)) { + const result = transitionSessionMode(data.session_id, parsed.arg, getDefaultMode()); + if (result) writeHookOutput('UserPromptSubmit', result.mode, result.context); + return; + } + + const mode = readSessionState(data.session_id)?.mode || getDefaultMode(); + writeHookOutput( + 'UserPromptSubmit', + mode, + 'PONYTAIL MODE ACTIVE — level: ' + mode, + ); + return; + } + + // One-shot skills carry their own instructions and never become session mode. + if (isOneShotCommand(parsed.command)) { + emitRepair(data.session_id, { initialize: false }); + return; + } + + if (isDeactivationCommand(prompt)) { + const result = transitionSessionMode(data.session_id, 'off', getDefaultMode()); + if (result) writeHookOutput('UserPromptSubmit', result.mode, result.context); + return; + } + + emitRepair(data.session_id); +} + +function handleLegacy(prompt) { + // Match /ponytail commands + let modeSwitched = false; + let deactivated = false; + if (/^[/@$]ponytail/.test(prompt)) { + const { arg, command: cmd, parts } = normalizedCommand(prompt); + + let mode = null; + let isReportOnly = false; + + if (cmd === '/ponytail-review' || cmd === '/ponytail:ponytail-review') { + mode = 'review'; + } else if (isControllerCommand(cmd)) { + // `/ponytail default ` 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 + '.'); } - } else if (mode === 'off') { - clearMode(); - deactivated = true; - writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF'); + return; + } + if (arg === 'lite') mode = 'lite'; + else if (arg === 'full') mode = 'full'; + else if (arg === 'ultra') mode = 'ultra'; + else if (arg === 'off') mode = 'off'; + else if (arg === '') { + isReportOnly = true; + mode = readMode() || getDefaultMode(); + } else { + mode = getDefaultMode(); } } - // Detect deactivation - if (!modeSwitched && !deactivated && isDeactivationCommand(prompt)) { + if (isReportOnly) { + writeHookOutput( + 'UserPromptSubmit', + 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'); } + } - // 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)); + // Detect deactivation + 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) {} } } - } catch (e) { - // Silent fail + 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)); + } } } - -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(); diff --git a/hooks/ponytail-runtime.js b/hooks/ponytail-runtime.js index 37d682f7..9090ddfc 100644 --- a/hooks/ponytail-runtime.js +++ b/hooks/ponytail-runtime.js @@ -1,7 +1,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); -const { getClaudeDir, getConfigDir } = require('./ponytail-config'); +const { getClaudeDir } = require('./ponytail-config'); const STATE_FILE = '.ponytail-active'; const isCopilot = Boolean(process.env.COPILOT_PLUGIN_DATA); @@ -16,16 +16,19 @@ if (isQoder) stateDir = path.join(os.homedir(), '.qoder'); const statePath = path.join(stateDir, STATE_FILE); function setMode(mode) { + if (isCodex) return; fs.mkdirSync(path.dirname(statePath), { recursive: true }); fs.writeFileSync(statePath, mode); } function clearMode() { + if (isCodex) return; try { fs.unlinkSync(statePath); } catch (e) {} } // Live mode written by activate/mode-tracker. Absent flag = ponytail off. function readMode() { + if (isCodex) return null; try { return fs.readFileSync(statePath, 'utf8').trim() || null; } catch (e) { diff --git a/hooks/ponytail-stdin.js b/hooks/ponytail-stdin.js new file mode 100644 index 00000000..6f5ab9c7 --- /dev/null +++ b/hooks/ponytail-stdin.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +const MAX_INPUT_BYTES = 1024 * 1024; + +function readHookPayload(callback, timeoutMs = 1000) { + let input = ''; + let bytes = 0; + let done = false; + let timer; + + function finish(exitAfter = false) { + if (done) return; + done = true; + if (timer) clearTimeout(timer); + + let payload = null; + try { + payload = JSON.parse(input.replace(/^\uFEFF/, '')); + } catch (_) {} + + try { callback(payload); } catch (_) {} + if (exitAfter) process.exit(0); + } + + process.stdin.on('data', (chunk) => { + bytes += Buffer.byteLength(chunk); + if (bytes > MAX_INPUT_BYTES) { + input = ''; + finish(true); + return; + } + input += chunk; + }); + process.stdin.on('end', () => finish()); + process.stdin.on('error', () => finish(true)); + + timer = setTimeout(() => finish(true), timeoutMs); + timer.unref(); +} + +module.exports = { readHookPayload }; diff --git a/hooks/ponytail-subagent.js b/hooks/ponytail-subagent.js index f2a7c776..e40f142e 100644 --- a/hooks/ponytail-subagent.js +++ b/hooks/ponytail-subagent.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -// ponytail — Claude Code SubagentStart hook +// ponytail — subagent-start activation hook // // SessionStart context is parent-thread only and never reaches subagents, so // without this every Task-spawned agent runs ponytail-unaware (issue #252). @@ -11,18 +11,13 @@ // "^general$" is exact. Unset means inject into every subagent, as before. const { getPonytailInstructions } = require('./ponytail-instructions'); -const { readMode, writeHookOutput } = require('./ponytail-runtime'); +const { getSubagentActivation } = require('./ponytail-codex-session'); +const { readHookPayload } = require('./ponytail-stdin'); +const { isCodex, 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() { +function inject(mode, context) { try { - writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode)); + writeHookOutput('SubagentStart', mode, context); } catch (e) { // Silent fail — a stdout error at hook exit must not surface as a hook failure. } @@ -38,40 +33,38 @@ try { 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); +if (isCodex) { + readHookPayload((payload) => { + if (!payload) return; + const agentType = String(payload.agent_type || '').trim(); + if (matcherRe && agentType && !matcherRe.test(agentType)) return; + const activation = getSubagentActivation(payload.session_id); + if (activation) inject(activation.mode, activation.context); + }); +} else { + activateLegacy(); } -// 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 activateLegacy() { + const mode = readMode(); -function finish() { - if (done) return; - done = true; + // Absent flag or off → ponytail isn't active; inject nothing. + if (!mode || mode === 'off') return; - 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); + // 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(mode, getPonytailInstructions(mode)); + return; } - 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(); + // Matcher set → skip only on a definite mismatch. Unparseable input fails + // open for legacy hosts, preserving the original subagent behavior. + readHookPayload((payload) => { + const agentType = payload ? String(payload.agent_type || '').trim() : ''; + if (agentType && !matcherRe.test(agentType)) return; + inject(mode, getPonytailInstructions(mode)); + }); +} diff --git a/package.json b/package.json index e1496801..21e5aa44 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "LICENSE" ], "scripts": { + "codex:local": "node scripts/build-codex-local.js", "test": "node --test tests/*.test.js && npm test --prefix pi-extension && npm test --prefix ponytail-mcp" }, "pi": { @@ -44,4 +45,3 @@ "access": "public" } } - diff --git a/scripts/build-codex-local.js b/scripts/build-codex-local.js new file mode 100644 index 00000000..5a5d37ac --- /dev/null +++ b/scripts/build-codex-local.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Build the Codex skill package, then install this checkout through a local +// Codex marketplace so global Codex loads local changes instead of GitHub main. + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ROOT = path.join(__dirname, '..'); +const MARKETPLACE_NAME = 'ponytail-local'; +const MARKETPLACE_ROOT = path.join(ROOT, '.codex-local-marketplace'); +const PLUGIN_SOURCE = './plugins/ponytail'; + +function localMarketplace() { + return { + name: MARKETPLACE_NAME, + interface: { displayName: 'Ponytail Local' }, + plugins: [ + { + name: 'ponytail', + source: { + source: 'local', + path: PLUGIN_SOURCE, + }, + policy: { + installation: 'AVAILABLE', + authentication: 'ON_INSTALL', + }, + category: 'Productivity', + }, + ], + }; +} + +function writeLocalMarketplace(root = ROOT, marketplaceRoot = MARKETPLACE_ROOT) { + const pluginPath = path.join(marketplaceRoot, 'plugins', 'ponytail'); + const file = path.join(marketplaceRoot, '.agents', 'plugins', 'marketplace.json'); + fs.rmSync(pluginPath, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(pluginPath), { recursive: true }); + fs.symlinkSync(root, pluginPath, process.platform === 'win32' ? 'junction' : 'dir'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(localMarketplace(), null, 2) + '\n'); + return file; +} + +function run(command, args, options = {}) { + console.log(`> ${[command, ...args].join(' ')}`); + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: 'inherit', + ...options, + }); + if (result.status === 0) return; + if (options.optional) return; + throw new Error(`${command} ${args.join(' ')} failed`); +} + +function main() { + run(process.execPath, [path.join(ROOT, 'scripts', 'build-codex-skills.js')]); + writeLocalMarketplace(); + + run('codex', ['plugin', 'remove', `ponytail@${MARKETPLACE_NAME}`], { optional: true }); + run('codex', ['plugin', 'remove', 'ponytail@ponytail'], { optional: true }); + run('codex', ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME], { optional: true }); + run('codex', ['plugin', 'marketplace', 'add', MARKETPLACE_ROOT]); + run('codex', ['plugin', 'add', `ponytail@${MARKETPLACE_NAME}`]); + + console.log('\nInstalled local Ponytail for Codex from:'); + console.log(ROOT); + console.log('Restart Codex, open /hooks, trust the hooks, then start a new thread.'); +} + +module.exports = { + MARKETPLACE_NAME, + MARKETPLACE_ROOT, + PLUGIN_SOURCE, + localMarketplace, + writeLocalMarketplace, +}; + +if (require.main === module) main(); diff --git a/scripts/build-codex-skills.js b/scripts/build-codex-skills.js new file mode 100644 index 00000000..96406ccc --- /dev/null +++ b/scripts/build-codex-skills.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node +// Generate the Codex-only skill package (.codex-plugin/skills/) from the +// canonical skills/. The ponytail entry is a small controller because Codex +// receives the active rules through lifecycle hooks. The five one-shot skills +// are copied byte for byte from their canonical sources. +// +// Run: node scripts/build-codex-skills.js +// tests/codex-skills.test.js fails if the committed output is stale. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..'); +const OUTPUT_ROOT = path.join(ROOT, '.codex-plugin', 'skills'); +const ONE_SHOT_NAMES = [ + 'ponytail-review', + 'ponytail-audit', + 'ponytail-debt', + 'ponytail-gain', + 'ponytail-help', +]; +const NAMES = ['ponytail', ...ONE_SHOT_NAMES]; + +const CONTROLLER = `--- +name: ponytail +description: > + Explicitly control Ponytail mode for the current Codex thread. + Use when the user invokes $ponytail to inspect, enable, change, or disable + lite, full, or ultra mode, or to configure the default for future threads. +argument-hint: "[lite|full|ultra|off|default ]" +license: MIT +--- + +# Ponytail Controller + +This Codex-only skill controls Ponytail's persistent mode for the current thread. +The lifecycle hook performs the transition and supplies the authoritative instructions for the selected mode. +Do not restate or emulate the Ponytail ruleset from this controller. + +## Commands + +- \`$ponytail\` reports the current mode without changing it. +- \`$ponytail lite\`, \`$ponytail full\`, and \`$ponytail ultra\` select a mode for the current thread. +- \`$ponytail off\` disables Ponytail for the current thread. +- \`$ponytail default \` changes only the default for future threads. + +After a mode command, follow the newest \`PONYTAIL CONTROL\` supplied by the lifecycle hook. +The one-shot review, audit, debt, gain, and help skills do not change the persistent mode. +`; + +const OPENAI_CONFIG = `policy: + allow_implicit_invocation: false + products: + - codex +`; + +const ONE_SHOT_OPENAI_CONFIG = `policy: + products: + - codex +`; + +// Codex always discovers /skills in addition to the manifest's +// custom skill root. Product-gate the portable copies so Codex sees only the +// generated skills, while ChatGPT and Atlas can still use the portable root. +const PORTABLE_OPENAI_CONFIG = `policy: + products: + - chatgpt + - atlas +`; + +function canonicalPath(name) { + return path.join(ROOT, 'skills', name, 'SKILL.md'); +} + +function outPath(name) { + return path.join(OUTPUT_ROOT, name, 'SKILL.md'); +} + +function portableConfigPath(name) { + return path.join(ROOT, 'skills', name, 'agents', 'openai.yaml'); +} + +function render(name) { + if (name === 'ponytail') return CONTROLLER; + if (!ONE_SHOT_NAMES.includes(name)) throw new Error(`unknown Codex skill: ${name}`); + return fs.readFileSync(canonicalPath(name)); +} + +function generatedFiles() { + const files = new Map(NAMES.map(name => [outPath(name), render(name)])); + files.set(path.join(OUTPUT_ROOT, 'ponytail', 'agents', 'openai.yaml'), OPENAI_CONFIG); + for (const name of ONE_SHOT_NAMES) { + files.set(path.join(OUTPUT_ROOT, name, 'agents', 'openai.yaml'), ONE_SHOT_OPENAI_CONFIG); + } + return files; +} + +module.exports = { + CONTROLLER, + NAMES, + ONE_SHOT_NAMES, + ONE_SHOT_OPENAI_CONFIG, + OPENAI_CONFIG, + OUTPUT_ROOT, + PORTABLE_OPENAI_CONFIG, + canonicalPath, + generatedFiles, + outPath, + portableConfigPath, + render, +}; + +if (require.main === module) { + for (const name of NAMES) { + if (fs.readFileSync(portableConfigPath(name), 'utf8') !== PORTABLE_OPENAI_CONFIG) { + throw new Error(`skills/${name} must be excluded from Codex by agents/openai.yaml`); + } + } + const files = generatedFiles(); + fs.rmSync(OUTPUT_ROOT, { recursive: true, force: true }); + for (const [file, contents] of files) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + console.log('wrote', path.relative(ROOT, file).replace(/\\/g, '/')); + } +} diff --git a/skills/ponytail-audit/agents/openai.yaml b/skills/ponytail-audit/agents/openai.yaml new file mode 100644 index 00000000..d2d1587c --- /dev/null +++ b/skills/ponytail-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + products: + - chatgpt + - atlas diff --git a/skills/ponytail-debt/agents/openai.yaml b/skills/ponytail-debt/agents/openai.yaml new file mode 100644 index 00000000..d2d1587c --- /dev/null +++ b/skills/ponytail-debt/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + products: + - chatgpt + - atlas diff --git a/skills/ponytail-gain/agents/openai.yaml b/skills/ponytail-gain/agents/openai.yaml new file mode 100644 index 00000000..d2d1587c --- /dev/null +++ b/skills/ponytail-gain/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + products: + - chatgpt + - atlas diff --git a/skills/ponytail-help/SKILL.md b/skills/ponytail-help/SKILL.md index ba145c0e..d669343c 100644 --- a/skills/ponytail-help/SKILL.md +++ b/skills/ponytail-help/SKILL.md @@ -32,9 +32,12 @@ Level sticks until changed or session end. | **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 all six as -slash commands). +Codex mode switching accepts the convenience hook form `$ponytail full` and +the explicit controller form `$ponytail:ponytail full`. +Codex one-shot skills use qualified names such as +`$ponytail:ponytail-review` and `$ponytail:ponytail-help`. +Claude Code and OpenCode use the slash-command forms above (OpenCode ships all +six as slash commands). ## Deactivate diff --git a/skills/ponytail-help/agents/openai.yaml b/skills/ponytail-help/agents/openai.yaml new file mode 100644 index 00000000..d2d1587c --- /dev/null +++ b/skills/ponytail-help/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + products: + - chatgpt + - atlas diff --git a/skills/ponytail-review/agents/openai.yaml b/skills/ponytail-review/agents/openai.yaml new file mode 100644 index 00000000..d2d1587c --- /dev/null +++ b/skills/ponytail-review/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + products: + - chatgpt + - atlas diff --git a/skills/ponytail/agents/openai.yaml b/skills/ponytail/agents/openai.yaml new file mode 100644 index 00000000..d2d1587c --- /dev/null +++ b/skills/ponytail/agents/openai.yaml @@ -0,0 +1,4 @@ +policy: + products: + - chatgpt + - atlas diff --git a/tests/codex-lifecycle.test.js b/tests/codex-lifecycle.test.js new file mode 100644 index 00000000..0b80078a --- /dev/null +++ b/tests/codex-lifecycle.test.js @@ -0,0 +1,444 @@ +#!/usr/bin/env node + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.join(__dirname, '..'); + +function workspace(t, defaultMode = 'off') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-codex-lifecycle-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return { + dir, + env: { + ...process.env, + HOME: path.join(dir, 'home'), + USERPROFILE: path.join(dir, 'home'), + XDG_CONFIG_HOME: path.join(dir, 'config'), + PLUGIN_DATA: path.join(dir, 'plugin-data'), + PONYTAIL_DEFAULT_MODE: defaultMode, + COPILOT_PLUGIN_DATA: '', + QODER_SESSION_ID: '', + }, + }; +} + +function runHook(script, env, payload) { + return spawnSync(process.execPath, [path.join(root, 'hooks', script)], { + env, + input: payload === undefined ? '' : JSON.stringify(payload), + encoding: 'utf8', + timeout: 4000, + }); +} + +function payloadBase(env, sessionId, event) { + return { + session_id: sessionId, + transcript_path: null, + cwd: env.PLUGIN_DATA, + hook_event_name: event, + model: 'gpt-5.3-codex', + permission_mode: 'default', + }; +} + +function start(env, sessionId, source = 'startup') { + return runHook('ponytail-activate.js', env, { + ...payloadBase(env, sessionId, 'SessionStart'), + source, + }); +} + +function prompt(env, sessionId, text, turn = 'turn-1') { + return runHook('ponytail-mode-tracker.js', env, { + ...payloadBase(env, sessionId, 'UserPromptSubmit'), + turn_id: turn, + prompt: text, + }); +} + +function subagent(env, sessionId, agentType = 'general-purpose') { + return runHook('ponytail-subagent.js', env, { + ...payloadBase(env, sessionId, 'SubagentStart'), + turn_id: 'subagent-turn-1', + agent_id: 'agent-1', + agent_type: agentType, + }); +} + +function outputOf(result) { + assert.equal(result.status, 0, result.stderr || result.error?.message); + const stdout = result.stdout.trim(); + return stdout ? JSON.parse(stdout) : null; +} + +function contextOf(result, event) { + const output = outputOf(result); + if (!output?.hookSpecificOutput) return null; + assert.equal(output.additionalContext, undefined, 'Codex context must not be top-level'); + assert.equal(output.hookSpecificOutput.hookEventName, event); + return output.hookSpecificOutput.additionalContext || null; +} + +function statePath(env, sessionId) { + const key = crypto.createHash('sha256').update(sessionId).digest('hex'); + return path.join(env.PLUGIN_DATA, 'sessions', `${key}.json`); +} + +function readState(env, sessionId) { + return JSON.parse(fs.readFileSync(statePath(env, sessionId), 'utf8')); +} + +function countBase(context) { + return (context?.match(/^PONYTAIL BASE\b/gm) || []).length; +} + +function countControls(context) { + return (context?.match(/^PONYTAIL CONTROL\b/gm) || []).length; +} + +function assertControl(context, mode) { + assert.match(context, new RegExp(`^PONYTAIL CONTROL\\b[^\\n]*\\bmode=${mode}\\b`, 'm')); +} + +function assertActivation(context, mode) { + assert.equal(countBase(context), 1, 'activation must contain one base ruleset'); + assert.equal(countControls(context), 1, 'activation must contain one mode control'); + assertControl(context, mode); + const base = context.match(/^PONYTAIL BASE hash=([a-f0-9]{64})$/m); + assert.ok(base, 'activation must identify its base hash'); + assert.match(context, /This base supersedes every earlier PONYTAIL BASE/); + assert.match( + context, + new RegExp(`^PONYTAIL CONTROL\\b[^\\n]*\\bbase=${base[1]}\\b`, 'm'), + 'active control must bind to the new base and exclude historical bases', + ); +} + +test('Codex startup supports off, lite, full, and ultra defaults', (t) => { + for (const mode of ['off', 'lite', 'full', 'ultra']) { + const { env } = workspace(t, mode); + const sessionId = `startup-${mode}`; + const context = contextOf(start(env, sessionId), 'SessionStart'); + const state = readState(env, sessionId); + + assert.equal(state.mode, mode); + assert.equal(state.generation, 1); + assert.equal(state.revision, 0); + if (mode === 'off') { + assert.equal(context, null); + assert.equal(state.baseHash, null); + } else { + assertActivation(context, mode); + assert.ok(state.baseHash); + } + } + + const { env } = workspace(t, 'review'); + const reviewContext = contextOf(start(env, 'invalid-review-default'), 'SessionStart'); + assertActivation(reviewContext, 'full'); + assert.equal(readState(env, 'invalid-review-default').mode, 'full'); +}); + +test('Codex clear resets each thread to its configured default', (t) => { + for (const mode of ['off', 'lite', 'full', 'ultra']) { + const { env } = workspace(t, mode); + const sessionId = `clear-${mode}`; + contextOf(start(env, sessionId), 'SessionStart'); + contextOf(prompt(env, sessionId, mode === 'full' ? '$ponytail lite' : '$ponytail full'), 'UserPromptSubmit'); + + const beforeClear = readState(env, sessionId); + const context = contextOf(start(env, sessionId, 'clear'), 'SessionStart'); + const cleared = readState(env, sessionId); + + assert.equal(cleared.mode, mode); + assert.equal(cleared.generation, beforeClear.generation + 1); + assert.equal(cleared.revision, 0); + if (mode === 'off') assert.equal(context, null); + else assertActivation(context, mode); + } +}); + +test('$ponytail default changes future Codex threads without changing the current thread', (t) => { + const { env } = workspace(t, 'full'); + delete env.PONYTAIL_DEFAULT_MODE; + const currentSession = 'default-command-current'; + assertActivation(contextOf(start(env, currentSession), 'SessionStart'), 'full'); + const before = readState(env, currentSession); + + const output = outputOf(prompt(env, currentSession, '$ponytail default off')); + assert.equal(output.systemMessage, 'PONYTAIL:FULL', 'default command reports the current thread mode'); + assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); + const context = output.hookSpecificOutput.additionalContext; + assert.match(context, /PONYTAIL DEFAULT SET/); + assert.equal(countBase(context), 0); + assert.equal(countControls(context), 0); + assert.deepEqual(readState(env, currentSession), before); + + const futureSession = 'default-command-future'; + assert.equal(contextOf(start(env, futureSession), 'SessionStart'), null); + assert.equal(readState(env, futureSession).mode, 'off'); +}); + +test('Codex default off activates late, changes mode without duplicating base, and ignores same-mode switches', (t) => { + const { env } = workspace(t, 'off'); + const sessionId = 'late-activation'; + + assert.equal(contextOf(start(env, sessionId), 'SessionStart'), null); + + const first = contextOf(prompt(env, sessionId, '$ponytail lite'), 'UserPromptSubmit'); + assertActivation(first, 'lite'); + const afterLite = readState(env, sessionId); + assert.equal(afterLite.revision, 1); + + const changed = contextOf(prompt(env, sessionId, '$ponytail full', 'turn-2'), 'UserPromptSubmit'); + assert.equal(countBase(changed), 0, 'lite -> full must reuse the existing base'); + assert.equal(countControls(changed), 1); + assertControl(changed, 'full'); + const afterFull = readState(env, sessionId); + assert.equal(afterFull.revision, 2); + + const repeated = contextOf(prompt(env, sessionId, '$ponytail full', 'turn-3'), 'UserPromptSubmit'); + assert.equal(repeated, null, 'same-mode switch must add no model context'); + assert.deepEqual(readState(env, sessionId), afterFull, 'same-mode switch must not revise state'); + + const namespaced = contextOf( + prompt(env, sessionId, '$ponytail:ponytail ultra', 'turn-4'), + 'UserPromptSubmit', + ); + assert.equal(countBase(namespaced), 0); + assertControl(namespaced, 'ultra'); + assert.equal(readState(env, sessionId).mode, 'ultra'); +}); + +test('Codex off and reactivation use authoritative controls and reuse a live base', (t) => { + const { env } = workspace(t, 'full'); + const sessionId = 'off-reactivate'; + + assertActivation(contextOf(start(env, sessionId), 'SessionStart'), 'full'); + const activeState = readState(env, sessionId); + + const off = contextOf(prompt(env, sessionId, '$ponytail off'), 'UserPromptSubmit'); + assert.equal(countBase(off), 0); + assert.equal(countControls(off), 1); + assertControl(off, 'off'); + const offState = readState(env, sessionId); + assert.equal(offState.mode, 'off'); + assert.equal(offState.baseHash, activeState.baseHash, 'off retains the base while it remains in history'); + + const active = contextOf(prompt(env, sessionId, '$ponytail ultra', 'turn-2'), 'UserPromptSubmit'); + assert.equal(countBase(active), 0, 'reactivation in the same generation must reuse the base'); + assert.equal(countControls(active), 1); + assertControl(active, 'ultra'); + assert.equal(readState(env, sessionId).mode, 'ultra'); +}); + +test('Codex resume, clear, and compact follow distinct lifecycle transitions', (t) => { + const { env } = workspace(t, 'lite'); + const sessionId = 'lifecycle-sources'; + + assertActivation(contextOf(start(env, sessionId), 'SessionStart'), 'lite'); + contextOf(prompt(env, sessionId, '$ponytail ultra'), 'UserPromptSubmit'); + const beforeResume = readState(env, sessionId); + + assert.equal(contextOf(start(env, sessionId, 'resume'), 'SessionStart'), null); + assert.deepEqual(readState(env, sessionId), beforeResume, 'resume preserves valid session state'); + + const compact = contextOf(start(env, sessionId, 'compact'), 'SessionStart'); + assertActivation(compact, 'ultra'); + const compacted = readState(env, sessionId); + assert.equal(compacted.mode, 'ultra'); + assert.equal(compacted.generation, beforeResume.generation + 1); + + const cleared = contextOf(start(env, sessionId, 'clear'), 'SessionStart'); + assertActivation(cleared, 'lite'); + const clearState = readState(env, sessionId); + assert.equal(clearState.mode, 'lite'); + assert.equal(clearState.generation, compacted.generation + 1); + assert.equal(clearState.revision, 0); + + contextOf(prompt(env, sessionId, '$ponytail off', 'turn-off'), 'UserPromptSubmit'); + const beforeOffCompact = readState(env, sessionId); + assert.equal(contextOf(start(env, sessionId, 'compact'), 'SessionStart'), null); + const offCompacted = readState(env, sessionId); + assert.equal(offCompacted.mode, 'off'); + assert.equal(offCompacted.generation, beforeOffCompact.generation + 1); + assert.equal(offCompacted.baseHash, null); +}); + +test('Codex state and subagent inheritance are isolated by session ID', (t) => { + const { env } = workspace(t, 'off'); + const sessionA = 'concurrent-session-a'; + const sessionB = 'concurrent-session-b'; + + contextOf(start(env, sessionA), 'SessionStart'); + contextOf(start(env, sessionB), 'SessionStart'); + assertActivation(contextOf(prompt(env, sessionA, '$ponytail full'), 'UserPromptSubmit'), 'full'); + + const subA = contextOf(subagent(env, sessionA), 'SubagentStart'); + assertActivation(subA, 'full'); + assert.equal(contextOf(subagent(env, sessionB), 'SubagentStart'), null); + assert.equal(readState(env, sessionA).mode, 'full'); + assert.equal(readState(env, sessionB).mode, 'off'); + + assertActivation(contextOf(prompt(env, sessionB, '$ponytail lite'), 'UserPromptSubmit'), 'lite'); + assert.equal(readState(env, sessionA).mode, 'full'); + assert.equal(readState(env, sessionB).mode, 'lite'); + + const scopedEnv = { ...env, PONYTAIL_SUBAGENT_MATCHER: '^plan$' }; + assert.equal(contextOf(subagent(scopedEnv, sessionA, 'general-purpose'), 'SubagentStart'), null); + assertActivation(contextOf(subagent(scopedEnv, sessionA, 'plan'), 'SubagentStart'), 'full'); +}); + +test('Codex missing and corrupt state recover safely without using the legacy global flag', (t) => { + const { env } = workspace(t, 'off'); + const missing = 'missing-state'; + const corrupt = 'corrupt-state'; + + const missingContext = contextOf(start(env, missing, 'resume'), 'SessionStart'); + assertControl(missingContext, 'off'); + assert.equal(countBase(missingContext), 0); + assert.equal(readState(env, missing).mode, 'off'); + + fs.mkdirSync(path.dirname(statePath(env, corrupt)), { recursive: true }); + fs.writeFileSync(statePath(env, corrupt), '{not-json'); + const corruptContext = contextOf(start(env, corrupt, 'resume'), 'SessionStart'); + assertControl(corruptContext, 'off'); + assert.equal(readState(env, corrupt).mode, 'off'); + + const noSession = outputOf(runHook('ponytail-mode-tracker.js', env, { + prompt: '$ponytail full', + hook_event_name: 'UserPromptSubmit', + })); + assert.equal(noSession, null); + assert.equal(fs.existsSync(path.join(env.PLUGIN_DATA, '.ponytail-active')), false); + + const malformed = spawnSync(process.execPath, [path.join(root, 'hooks', 'ponytail-activate.js')], { + env, + input: '{not-json', + encoding: 'utf8', + timeout: 4000, + }); + assert.equal(malformed.status, 0, malformed.stderr); + assert.equal(malformed.stdout, ''); + + for (const sessionId of ['missing-explicit-off', 'corrupt-explicit-off']) { + if (sessionId.startsWith('corrupt')) { + fs.mkdirSync(path.dirname(statePath(env, sessionId)), { recursive: true }); + fs.writeFileSync(statePath(env, sessionId), '{not-json'); + } + const off = contextOf(prompt(env, sessionId, '$ponytail off'), 'UserPromptSubmit'); + assertControl(off, 'off'); + assert.equal(countBase(off), 0); + assert.equal(readState(env, sessionId).mode, 'off'); + } +}); + +test('bare controller and one-shot skills are state-silent when Codex state is absent or corrupt', (t) => { + const { env } = workspace(t, 'off'); + const missing = 'bare-missing-state'; + const missingStatus = contextOf(prompt(env, missing, '$ponytail'), 'UserPromptSubmit'); + assert.match(missingStatus, /PONYTAIL MODE ACTIVE — level: off/); + assert.equal(countBase(missingStatus), 0); + assert.equal(countControls(missingStatus), 0); + assert.equal(fs.existsSync(statePath(env, missing)), false); + + const corrupt = 'bare-corrupt-state'; + fs.mkdirSync(path.dirname(statePath(env, corrupt)), { recursive: true }); + fs.writeFileSync(statePath(env, corrupt), '{not-json'); + const corruptStatus = contextOf(prompt(env, corrupt, '$ponytail'), 'UserPromptSubmit'); + assert.match(corruptStatus, /PONYTAIL MODE ACTIVE — level: off/); + assert.equal(countBase(corruptStatus), 0); + assert.equal(countControls(corruptStatus), 0); + assert.equal(fs.readFileSync(statePath(env, corrupt), 'utf8'), '{not-json'); + + for (const skill of ['review', 'audit', 'debt', 'gain', 'help']) { + const sessionId = `missing-one-shot-${skill}`; + assert.equal(outputOf(prompt(env, sessionId, `$ponytail:ponytail-${skill}`)), null); + assert.equal(fs.existsSync(statePath(env, sessionId)), false); + } +}); + +test('resume recovery outranks controls from a deleted higher-generation state', (t) => { + const { env } = workspace(t, 'full'); + const sessionId = 'resume-recovery-authority'; + contextOf(start(env, sessionId), 'SessionStart'); + contextOf(start(env, sessionId, 'compact'), 'SessionStart'); + const beforeLoss = readState(env, sessionId); + assert.ok(beforeLoss.generation > 1); + fs.unlinkSync(statePath(env, sessionId)); + + const recovered = contextOf(start(env, sessionId, 'resume'), 'SessionStart'); + assertActivation(recovered, 'full'); + const recoveredState = readState(env, sessionId); + assert.ok(recoveredState.generation > beforeLoss.generation); + assert.match(recovered, new RegExp(`^PONYTAIL CONTROL\\b[^\\n]*generation=${recoveredState.generation}\\b`, 'm')); +}); + +test('Codex repairs a stale active base once on an ordinary prompt', (t) => { + const { env } = workspace(t, 'full'); + const sessionId = 'stale-base'; + contextOf(start(env, sessionId), 'SessionStart'); + + const state = readState(env, sessionId); + fs.writeFileSync(statePath(env, sessionId), JSON.stringify({ ...state, baseHash: '0'.repeat(64) })); + + const repaired = contextOf(prompt(env, sessionId, 'continue the implementation'), 'UserPromptSubmit'); + assertActivation(repaired, 'full'); + const repairedState = readState(env, sessionId); + assert.notEqual(repairedState.baseHash, '0'.repeat(64)); + assert.equal(repairedState.mode, state.mode); + assert.equal(repairedState.generation, state.generation); + assert.equal(repairedState.revision, state.revision); + + assert.equal( + contextOf(prompt(env, sessionId, 'continue again', 'turn-2'), 'UserPromptSubmit'), + null, + 'matching base must not be repaired twice', + ); +}); + +test('Codex ignores interrupted temporary writes beside canonical session state', (t) => { + const { env } = workspace(t, 'full'); + const sessionId = 'interrupted-write'; + contextOf(start(env, sessionId), 'SessionStart'); + const before = readState(env, sessionId); + fs.writeFileSync(`${statePath(env, sessionId)}.interrupted.tmp`, '{not-json'); + + assert.equal(contextOf(start(env, sessionId, 'resume'), 'SessionStart'), null); + assert.deepEqual(readState(env, sessionId), before); + + const changed = contextOf(prompt(env, sessionId, '$ponytail lite'), 'UserPromptSubmit'); + assertControl(changed, 'lite'); + assert.equal(readState(env, sessionId).mode, 'lite'); +}); + +test('Codex one-shot skills never mutate persistent mode', (t) => { + const { env } = workspace(t, 'full'); + const sessionId = 'one-shot-skills'; + contextOf(start(env, sessionId), 'SessionStart'); + const before = readState(env, sessionId); + + for (const skill of ['review', 'audit', 'debt', 'gain', 'help']) { + const command = `$ponytail:ponytail-${skill}`; + const result = prompt(env, sessionId, command, `turn-${skill}`); + assert.equal(contextOf(result, 'UserPromptSubmit'), null, `${command} must not inject mode context`); + assert.deepEqual(readState(env, sessionId), before, `${command} must not change mode state`); + } +}); + +test('Codex hook output keeps additionalContext under hookSpecificOutput', (t) => { + const { env } = workspace(t, 'full'); + const output = outputOf(start(env, 'output-schema')); + + assert.equal(output.systemMessage, 'PONYTAIL:FULL'); + assert.equal(output.additionalContext, undefined); + assert.equal(output.hookSpecificOutput.hookEventName, 'SessionStart'); + assert.equal(typeof output.hookSpecificOutput.additionalContext, 'string'); +}); diff --git a/tests/codex-local-build.test.js b/tests/codex-local-build.test.js new file mode 100644 index 00000000..87e4ee52 --- /dev/null +++ b/tests/codex-local-build.test.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + MARKETPLACE_NAME, + PLUGIN_SOURCE, + localMarketplace, + writeLocalMarketplace, +} = require('../scripts/build-codex-local'); + +test('local Codex marketplace points at its bundled plugin path', () => { + const manifest = localMarketplace(); + + assert.equal(manifest.name, MARKETPLACE_NAME); + assert.equal(manifest.plugins.length, 1); + assert.equal(manifest.plugins[0].name, 'ponytail'); + assert.deepEqual(manifest.plugins[0].source, { + source: 'local', + path: PLUGIN_SOURCE, + }); +}); + +test('local Codex marketplace writer creates the Codex-discovered manifest path', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-local-marketplace-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const file = writeLocalMarketplace('/tmp/ponytail-checkout', dir); + assert.equal(path.relative(dir, file), path.join('.agents', 'plugins', 'marketplace.json')); + + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(manifest.plugins[0].source.path, PLUGIN_SOURCE); + assert.equal( + fs.readlinkSync(path.join(dir, 'plugins', 'ponytail')), + '/tmp/ponytail-checkout', + ); +}); diff --git a/tests/codex-skills.test.js b/tests/codex-skills.test.js new file mode 100644 index 00000000..e765d277 --- /dev/null +++ b/tests/codex-skills.test.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// The Codex skill package (.codex-plugin/skills/) is generated by +// scripts/build-codex-skills.js. These tests fail when the committed tree is +// stale, contains extra files, or lets Codex invoke the mode controller +// implicitly. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { + CONTROLLER, + NAMES, + ONE_SHOT_NAMES, + ONE_SHOT_OPENAI_CONFIG, + OPENAI_CONFIG, + OUTPUT_ROOT, + PORTABLE_OPENAI_CONFIG, + canonicalPath, + generatedFiles, + outPath, + portableConfigPath, +} = require('../scripts/build-codex-skills'); + +const ROOT = path.join(__dirname, '..'); + +function filesUnder(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? filesUnder(file) : [file]; + }); +} + +test('Codex manifest points at the generated skill root', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, '.codex-plugin', 'plugin.json'), 'utf8')); + assert.equal(manifest.skills, './.codex-plugin/skills/'); +}); + +test('committed Codex skill tree contains exactly the generated files', () => { + const relative = file => path.relative(OUTPUT_ROOT, file).replace(/\\/g, '/'); + const actual = filesUnder(OUTPUT_ROOT).map(relative).sort(); + const expected = [...generatedFiles().keys()].map(relative).sort(); + assert.deepEqual(actual, expected, 'stale files found — run: node scripts/build-codex-skills.js'); + assert.equal(NAMES.length, 6); +}); + +test('Codex controller matches the generator and contains no canonical rules body', () => { + const onDisk = fs.readFileSync(outPath('ponytail'), 'utf8'); + assert.equal(onDisk, CONTROLLER, 'stale — run: node scripts/build-codex-skills.js'); + + const canonical = fs.readFileSync(canonicalPath('ponytail'), 'utf8'); + assert.notEqual(onDisk, canonical); + for (const marker of ['## The ladder', '## Rules', '## Intensity', '## When NOT to be lazy']) { + assert.equal(onDisk.includes(marker), false, `controller copied canonical section: ${marker}`); + } +}); + +test('only the Codex controller disables implicit invocation', () => { + const configPath = path.join(OUTPUT_ROOT, 'ponytail', 'agents', 'openai.yaml'); + assert.equal(fs.readFileSync(configPath, 'utf8'), OPENAI_CONFIG); + assert.match(OPENAI_CONFIG, / allow_implicit_invocation: false\n/); + assert.match(OPENAI_CONFIG, / products:\n - codex\n$/); + + for (const name of ONE_SHOT_NAMES) { + const oneShotConfig = fs.readFileSync( + path.join(OUTPUT_ROOT, name, 'agents', 'openai.yaml'), + 'utf8', + ); + assert.equal(oneShotConfig, ONE_SHOT_OPENAI_CONFIG); + assert.equal(oneShotConfig.includes('allow_implicit_invocation'), false); + } +}); + +test('all portable skills are product-gated out of Codex', () => { + for (const name of NAMES) { + const config = fs.readFileSync(portableConfigPath(name), 'utf8'); + assert.equal(config, PORTABLE_OPENAI_CONFIG, `${name} is still discoverable from the portable root`); + assert.match(config, / - chatgpt\n - atlas\n$/); + assert.equal(config.includes(' - codex\n'), false); + } +}); + +for (const name of ONE_SHOT_NAMES) { + test(`${name}: committed Codex skill is the canonical source byte for byte`, () => { + assert.deepEqual( + fs.readFileSync(outPath(name)), + fs.readFileSync(canonicalPath(name)), + 'stale — run: node scripts/build-codex-skills.js', + ); + }); +} diff --git a/tests/hooks-windows.test.js b/tests/hooks-windows.test.js index 0ec878e1..19fd5849 100644 --- a/tests/hooks-windows.test.js +++ b/tests/hooks-windows.test.js @@ -8,6 +8,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { spawn } = require('child_process'); @@ -89,21 +90,37 @@ test('every hook command points at a script that ships in hooks/', () => { // `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'); +async function assertOpenStdinDoesNotHang(script, env = {}) { + const hook = path.join(root, 'hooks', script); // stdin is a pipe we never write to or end, reproducing the deadlock. - const child = spawn(process.execPath, [hook], { stdio: ['pipe', 'ignore', 'ignore'] }); + const child = spawn(process.execPath, [hook], { + env: { ...process.env, ...env }, + 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')); + reject(new Error(`${script} 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('ponytail-mode-tracker self-exits when stdin never closes (no freeze)', async () => { + await assertOpenStdinDoesNotHang('ponytail-mode-tracker.js'); +}); + +test('Codex stdin-reading lifecycle hooks self-exit when stdin never closes', async (t) => { + const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-open-stdin-')); + t.after(() => fs.rmSync(pluginData, { recursive: true, force: true })); + const env = { PLUGIN_DATA: pluginData, COPILOT_PLUGIN_DATA: '' }; + + await assertOpenStdinDoesNotHang('ponytail-activate.js', env); + await assertOpenStdinDoesNotHang('ponytail-subagent.js', env); }); test('Claude and Codex manifests point at the shared host-specific hook config', () => { diff --git a/tests/hooks.test.js b/tests/hooks.test.js index bf4a8eb3..8e057131 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -1,6 +1,7 @@ #!/usr/bin/env node const assert = require('assert'); +const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -26,6 +27,23 @@ function run(script, env, input = '') { }); } +function codexInput(event, sessionId, extra = {}) { + return JSON.stringify({ + session_id: sessionId, + transcript_path: null, + cwd: root, + hook_event_name: event, + model: 'gpt-5.3-codex', + permission_mode: 'default', + ...extra, + }); +} + +function codexStatePath(pluginData, sessionId) { + const key = crypto.createHash('sha256').update(sessionId).digest('hex'); + return path.join(pluginData, 'sessions', `${key}.json`); +} + // 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 / @@ -53,38 +71,46 @@ const codexEnv = { PLUGIN_DATA: pluginData, PONYTAIL_DEFAULT_MODE: 'ultra', }; -const codexState = path.join(pluginData, '.ponytail-active'); +const codexSession = 'hooks-compat-session'; +const codexState = codexStatePath(pluginData, codexSession); -let result = run('ponytail-activate.js', codexEnv); +let result = run( + 'ponytail-activate.js', + codexEnv, + codexInput('SessionStart', codexSession, { source: 'startup' }), +); assert.equal(result.status, 0, result.stderr); -assert.equal(fs.readFileSync(codexState, 'utf8'), 'ultra'); +assert.equal(JSON.parse(fs.readFileSync(codexState, 'utf8')).mode, '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/, + /^PONYTAIL BASE\b/m, ); +assert.match(output.hookSpecificOutput.additionalContext, /^PONYTAIL CONTROL\b.*\bmode=ultra\b/m); result = run( 'ponytail-mode-tracker.js', codexEnv, - JSON.stringify({ prompt: '@ponytail lite' }), + codexInput('UserPromptSubmit', codexSession, { turn_id: 'turn-lite', prompt: '$ponytail lite' }), ); assert.equal(result.status, 0, result.stderr); -assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite'); +assert.equal(JSON.parse(fs.readFileSync(codexState, 'utf8')).mode, 'lite'); output = JSON.parse(result.stdout); assert.equal(output.systemMessage, 'PONYTAIL:LITE'); +assert.doesNotMatch(output.hookSpecificOutput.additionalContext, /^PONYTAIL BASE\b/m); +assert.match(output.hookSpecificOutput.additionalContext, /^PONYTAIL CONTROL\b.*\bmode=lite\b/m); -// Querying bare @ponytail should report the active level ('lite') without resetting it to default ('ultra') +// Querying bare $ponytail reports the active level without resetting it to the default. result = run( 'ponytail-mode-tracker.js', codexEnv, - JSON.stringify({ prompt: '@ponytail' }), + codexInput('UserPromptSubmit', codexSession, { turn_id: 'turn-status', prompt: '$ponytail' }), ); assert.equal(result.status, 0, result.stderr); -assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite'); +assert.equal(JSON.parse(fs.readFileSync(codexState, 'utf8')).mode, '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'); @@ -96,26 +122,33 @@ assert.match( result = run( 'ponytail-mode-tracker.js', codexEnv, - JSON.stringify({ prompt: 'normal mode' }), + codexInput('UserPromptSubmit', codexSession, { turn_id: 'turn-off', prompt: 'normal mode' }), ); assert.equal(result.status, 0, result.stderr); -assert.equal(fs.existsSync(codexState), false); +assert.equal(JSON.parse(fs.readFileSync(codexState, 'utf8')).mode, 'off'); output = JSON.parse(result.stdout); assert.equal(output.systemMessage, 'PONYTAIL:OFF'); // A request that merely mentions "normal mode" must not deactivate ponytail. -result = run('ponytail-mode-tracker.js', codexEnv, JSON.stringify({ prompt: '@ponytail lite' })); +result = run( + 'ponytail-mode-tracker.js', + codexEnv, + codexInput('UserPromptSubmit', codexSession, { turn_id: 'turn-reactivate', prompt: '$ponytail lite' }), +); assert.equal(result.status, 0, result.stderr); -assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite'); +assert.equal(JSON.parse(fs.readFileSync(codexState, 'utf8')).mode, 'lite'); result = run( 'ponytail-mode-tracker.js', codexEnv, - JSON.stringify({ prompt: 'add a normal mode toggle next to dark mode' }), + codexInput('UserPromptSubmit', codexSession, { + turn_id: 'turn-incidental-normal', + prompt: 'add a normal mode toggle next to dark mode', + }), ); assert.equal(result.status, 0, result.stderr); assert.equal( - fs.readFileSync(codexState, 'utf8'), + JSON.parse(fs.readFileSync(codexState, 'utf8')).mode, 'lite', 'incidental "normal mode" in a request must not turn ponytail off', ); @@ -246,14 +279,35 @@ assert.equal(result.stdout, '', 'SubagentStart must stay silent when ponytail is // 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 }); +const subCodexSession = 'subagent-codex-session'; +const subCodexEnv = { + HOME: subHome, + USERPROFILE: subHome, + PLUGIN_DATA: subCodex, + PONYTAIL_DEFAULT_MODE: 'full', +}; +result = run( + 'ponytail-activate.js', + subCodexEnv, + codexInput('SessionStart', subCodexSession, { source: 'startup' }), +); +assert.equal(result.status, 0, result.stderr); +result = run( + 'ponytail-subagent.js', + subCodexEnv, + codexInput('SubagentStart', subCodexSession, { + turn_id: 'sub-turn', + agent_id: 'sub-agent', + agent_type: 'general-purpose', + }), +); 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/); +assert.match(output.hookSpecificOutput.additionalContext, /^PONYTAIL BASE\b/m); +assert.match(output.hookSpecificOutput.additionalContext, /^PONYTAIL CONTROL\b.*\bmode=full\b/m); // SubagentStart scoping (issue #506): PONYTAIL_SUBAGENT_MATCHER limits the // injection to agent types whose name matches the regex. Unset keeps the diff --git a/tests/package-scripts.test.js b/tests/package-scripts.test.js index 3bea2a14..6e4f3b44 100644 --- a/tests/package-scripts.test.js +++ b/tests/package-scripts.test.js @@ -23,3 +23,9 @@ test('CI installs MCP dependencies before root npm test', () => { 'MCP dependencies must be installed before the root test command runs', ); }); + +test('package exposes the local Codex build script', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + + assert.equal(packageJson.scripts['codex:local'], 'node scripts/build-codex-local.js'); +});