diff --git a/.agents/setup b/.agents/setup index e7f3fae..97a59ac 100755 --- a/.agents/setup +++ b/.agents/setup @@ -36,4 +36,6 @@ if [[ ! -f .env ]]; then cp -- .env.example .env fi +exec "$SHELL" -l + echo "Orb setup complete" diff --git a/.github/README.md b/.github/README.md index d2ba323..1dfd44e 100644 --- a/.github/README.md +++ b/.github/README.md @@ -37,6 +37,19 @@ Install all skills: npx skills@latest add o-az/skills --all ``` +## Amp plugins + +### Ponytail + +The [Ponytail plugin](/plugins#ponytail) adds always-on, mode-aware +[Ponytail](https://github.com/dietrichgebert/ponytail) support to Amp: + +```bash +mkdir -p ~/.config/amp/plugins +curl -fsSL https://raw.githubusercontent.com/o-az/skills/main/plugins/ponytail.ts \ + -o ~/.config/amp/plugins/ponytail.ts +``` + ## Contributing a new Skill 0. Copy skill template diff --git a/bun.lock b/bun.lock index 851e6d8..2d2c841 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "skills", "devDependencies": { + "@ampcode/plugin": "^0.0.0-20260805002929-g5b1c43a", "oxfmt": "^0.61.0", "oxlint": "^1.76.0", "oxlint-tsgolint": "^7.0.2001", @@ -13,6 +14,8 @@ }, }, "packages": { + "@ampcode/plugin": ["@ampcode/plugin@0.0.0-20260805002929-g5b1c43a", "", {}, "sha512-+0jQrtXyKWupvoEfeDw+1TWqVWmjjyv9Ovpc9FmQq5iR1nrXpoeLKsBv16KS7O5q51eNvECkZhbX9K5nybH51g=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw=="], diff --git a/mise.toml b/mise.toml index 0e3ee20..06f7669 100644 --- a/mise.toml +++ b/mise.toml @@ -5,15 +5,7 @@ color = true pipx.uvx = true experimental = true color_theme = "charm" -minimum_release_age_excludes = [ - "hk", - "uv", - "pkl", - "bun", - "node", - "npm:oxfmt", - "npm:oxlint" -] +minimum_release_age_excludes = ["hk", "uv", "pkl", "bun", "node", "npm:oxfmt", "npm:oxlint"] [env] HK_MISE = 1 diff --git a/package.json b/package.json index 74fac48..2c09943 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "validate-skills": "bash -c 'for dir in skills/*/; do uvx --from skills-ref agentskills validate $dir; done'" }, "devDependencies": { + "@ampcode/plugin": "^0.0.0-20260805002929-g5b1c43a", "oxfmt": "^0.61.0", "oxlint": "^1.76.0", "oxlint-tsgolint": "^7.0.2001", diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..72a4b14 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,64 @@ +# Amp plugins + +## Ponytail + +[Ponytail](https://github.com/dietrichgebert/ponytail) forces the smallest +solution that actually works. The Amp plugin applies Ponytail on every agent +turn, keeps intensity modes isolated per thread, and exposes native controls in +Amp's command palette. + +Install it as a system plugin on macOS or Linux: + +```sh +mkdir -p ~/.config/amp/plugins +curl -fsSL https://raw.githubusercontent.com/o-az/skills/main/plugins/ponytail.ts \ + -o ~/.config/amp/plugins/ponytail.ts +``` + +Then run `plugins: reload` from Amp's command palette or restart Amp. + +The current Amp CLI only accepts Amp-hosted URLs in `amp plugins add`. Rerun +the command above to update this GitHub-hosted plugin. + +### Use + +Ponytail starts in `full` mode. Open the command palette and use: + +- `ponytail: Change mode` — set `off`, `lite`, `full`, or `ultra` for the + active thread. +- `ponytail: Show status` — show the active and default modes. +- `ponytail: Set default mode` — save the mode used by new threads. +- `ponytail: Open documentation` — open Ponytail's upstream documentation. + +The equivalent prompt commands also work. The `/` prefix is optional; `@` and +`$` are accepted for compatibility with Ponytail's other adapters. + +```text +/ponytail status +/ponytail lite +/ponytail full +/ponytail ultra +/ponytail off +/ponytail default full +stop ponytail +normal mode +``` + +`stop ponytail` and `normal mode` only deactivate Ponytail when the entire +prompt is the command, so ordinary requests containing either phrase do not +switch modes accidentally. + +Thread modes last for the current Amp process and reset to the configured +default after Amp or the plugin restarts. + +Set `PONYTAIL_DEFAULT_MODE=off|lite|full|ultra` to override the saved default. + +Remove the plugin with: + +```sh +rm ~/.config/amp/plugins/ponytail.ts +``` + +The Ponytail instructions embedded in the plugin are adapted from +`dietrichgebert/ponytail` and distributed under its MIT license; the complete +license notice is included in `ponytail.ts`. diff --git a/plugins/ponytail.ts b/plugins/ponytail.ts new file mode 100644 index 0000000..d3e4a42 --- /dev/null +++ b/plugins/ponytail.ts @@ -0,0 +1,399 @@ +import type { PluginAPI, PluginCommandContext, ThreadID } from "@ampcode/plugin"; + +export const description = + "Adds always-on Ponytail guidance, per-thread intensity modes, and native mode controls to Amp."; + +export const PONYTAIL_MODES = ["off", "lite", "full", "ultra"] as const; +export type PonytailMode = (typeof PONYTAIL_MODES)[number]; + +export type PonytailCommand = + | { type: "set-mode"; mode: PonytailMode } + | { type: "set-default"; mode: PonytailMode } + | { type: "status" } + | { type: "invalid" }; + +const DEFAULT_MODE: PonytailMode = "full"; +const DEFAULT_MODE_KEY = "ponytail.defaultMode"; +const PONYTAIL_URL = "https://github.com/dietrichgebert/ponytail"; + +// Adapted from Ponytail's canonical skills/ponytail/SKILL.md. Keeping the +// complete body here makes the installed, single-file Amp plugin work offline. +const PONYTAIL_SKILL_BODY = `# Ponytail + +You are a lazy senior developer. Lazy means efficient, not careless. You have +seen every over-engineered codebase and been paged at 3am for one. The best +code is the code never written. + +## Persistence + +ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if +unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. +Switch: \`/ponytail lite|full|ultra\`. + +## The ladder + +Stop at the first rung that holds: + +1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) +2. **Already in this codebase?** A helper, util, type, or pattern that already lives here → reuse it. Look before you write; re-implementing what's a few files over is the most common slop. +3. **Stdlib does it?** Use it. +4. **Native platform feature covers it?** \`\` over a picker lib, CSS over JS, DB constraint over app code. +5. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +6. **Can it be one line?** One line. +7. **Only then:** the minimum code that works. + +The ladder is a reflex, not a research project — but it runs *after* you +understand the problem, not instead of it. Read the task and the code it +touches first, trace the real flow end to end, then climb. Two rungs work → +take the higher one and move on. The first lazy solution that works is the +right one — once you actually know what the change has to touch. + +**Bug fix = root cause, not symptom.** A report names a symptom. Before you +edit, grep every caller of the function you're about to touch. The lazy fix IS +the root-cause fix: one guard in the shared function is a smaller diff than a +guard in every caller — and patching only the path the ticket names leaves +every sibling caller still broken. Fix it once, where all callers route through. + +## Rules + +- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. +- No boilerplate, no scaffolding "for later", later can scaffold for itself. +- Deletion over addition. Boring over clever, clever is what someone decodes at 3am. +- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. +- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. +- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a \`ponytail:\` comment naming the ceiling and upgrade path (\`# ponytail: global lock, per-account locks if throughput matters\`). + +## Output + +Code first. Then at most three short lines: what was skipped, when to add it. +No essays, no feature tours, no design notes. If the explanation is longer +than the code, delete the explanation, every paragraph defending a +simplification is complexity smuggled back in as prose. Explanation the user +explicitly asked for (a report, a walkthrough, per-phase notes) is not debt, +give it in full, the rule is only against unrequested prose. + +Pattern: \`[code] → skipped: [X], add when [Y].\` + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. | +| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. | +| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. | + +Example: "Add a cache for these API responses." +- lite: "Done, cache added. FYI: \`functools.lru_cache\` covers this in one line if you'd rather not own a cache class." +- full: "\`@lru_cache(maxsize=1000)\` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short." +- ultra: "No cache until a profiler says so. When it does: \`@lru_cache\`. A hand-rolled TTL cache class is a bug farm with a hit rate." + +## When NOT to be lazy + +Never simplify away: input validation at trust boundaries, error handling +that prevents data loss, security measures, accessibility basics, anything +explicitly requested. User insists on the full version → build it, no +re-arguing. + +Never lazy about understanding the problem. The ladder shortens the +solution, never the reading. Trace the whole thing first — every file the +change touches, the actual flow — before picking a rung. Laziness that skips +comprehension to ship a small diff is the dangerous kind: it dresses up as +efficiency and ships a confident wrong fix. Read fully, then be lazy. + +Hardware is never the ideal on paper: a real clock drifts, a real sensor +reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not +just less code, the physical world needs tuning a minimal model can't see. + +Lazy code without its check is unfinished. Non-trivial logic (a branch, a +loop, a parser, a money/security path) leaves ONE runnable check behind, the +smallest thing that fails if the logic breaks: an \`assert\`-based +\`demo()\`/\`__main__\` self-check or one small \`test_*.py\`. No frameworks, +no fixtures, no per-function suites unless asked. Trivial one-liners need no +test, YAGNI applies to tests too. + +## Boundaries + +Ponytail governs what you build, not how you talk (pair with Caveman for +terse prose). "stop ponytail" / "normal mode": revert. Level persists until +changed or session end. + +The shortest path to done is the right path.`; + +export function normalizeMode(value: unknown): PonytailMode | null { + if (typeof value !== "string") return null; + + const normalized = value.trim().toLowerCase(); + return PONYTAIL_MODES.includes(normalized as PonytailMode) ? (normalized as PonytailMode) : null; +} + +export function isDeactivationCommand(value: unknown): boolean { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[.!?\s]+$/, ""); + + return normalized === "stop ponytail" || normalized === "normal mode"; +} + +export function parsePonytailCommand(value: unknown): PonytailCommand | null { + if (isDeactivationCommand(value)) { + return { type: "set-mode", mode: "off" }; + } + + const command = String(value ?? "") + .trim() + .toLowerCase() + .match(/^(?:[/@$])?ponytail(?::ponytail)?(?:\s+(.*))?$/); + + if (!command) return null; + + const args = command[1]?.trim(); + if (!args || args === "status") return { type: "status" }; + + const parts = args.split(/\s+/); + if (parts[0] === "default") { + const mode = parts.length === 2 ? normalizeMode(parts[1]) : null; + return mode ? { type: "set-default", mode } : { type: "invalid" }; + } + + const mode = parts.length === 1 ? normalizeMode(parts[0]) : null; + return mode ? { type: "set-mode", mode } : { type: "invalid" }; +} + +export function filterSkillBodyForMode(body: string, mode: PonytailMode): string { + return body + .split(/\r?\n/) + .filter((line) => { + const tableLabel = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/); + if (tableLabel) { + const labelMode = normalizeMode(tableLabel[1]); + if (labelMode && labelMode !== "off") return labelMode === mode; + } + + const exampleLabel = line.match(/^-\s*([^:]+):\s*"/); + if (exampleLabel) { + const labelMode = normalizeMode(exampleLabel[1]); + if (labelMode && labelMode !== "off") return labelMode === mode; + } + + return true; + }) + .join("\n"); +} + +export function getPonytailInstructions(mode: PonytailMode): string { + if (mode === "off") return ""; + + return `PONYTAIL MODE ACTIVE — level: ${mode}\n\n${filterSkillBodyForMode(PONYTAIL_SKILL_BODY, mode)}`; +} + +function effectiveDefaultMode(config: Record): PonytailMode { + return ( + normalizeMode(process.env.PONYTAIL_DEFAULT_MODE) ?? + normalizeMode(config[DEFAULT_MODE_KEY]) ?? + DEFAULT_MODE + ); +} + +function requireThread(ctx: PluginCommandContext): ctx is PluginCommandContext & { + thread: NonNullable; +} { + return Boolean(ctx.thread); +} + +export default function ponytailPlugin(amp: PluginAPI) { + const threadModes = new Map(); + let configuredDefaultMode: PonytailMode = + normalizeMode(process.env.PONYTAIL_DEFAULT_MODE) ?? DEFAULT_MODE; + let receivedConfigurationUpdate = false; + + const applyConfiguration = (config: Record) => { + configuredDefaultMode = effectiveDefaultMode(config); + }; + + const loadConfiguration = amp.configuration + .get() + .then((config) => { + if (!receivedConfigurationUpdate) applyConfiguration(config); + }) + .catch((error: unknown) => { + amp.logger.log("Unable to read Ponytail configuration; using the fallback default.", error); + }); + const configurationSubscription = amp.configuration.subscribe((config) => { + receivedConfigurationUpdate = true; + applyConfiguration(config); + }); + + amp.onDispose(() => { + configurationSubscription.unsubscribe(); + }); + + const getThreadMode = async (threadID: ThreadID) => { + await loadConfiguration; + if (!threadModes.has(threadID)) { + threadModes.set(threadID, configuredDefaultMode); + } + return threadModes.get(threadID) as PonytailMode; + }; + + const setThreadMode = (threadID: ThreadID, mode: PonytailMode) => { + threadModes.set(threadID, mode); + }; + + const setDefaultMode = async (mode: PonytailMode) => { + await amp.configuration.update({ [DEFAULT_MODE_KEY]: mode }, "global"); + configuredDefaultMode = normalizeMode(process.env.PONYTAIL_DEFAULT_MODE) ?? mode; + }; + + const notify = async (message: string, ui: PluginCommandContext["ui"]) => { + try { + await ui.notify(message); + } catch (error) { + if (!(error instanceof Error) || !amp.helpers.isPluginUINotAvailableError(error)) { + throw error; + } + } + }; + + amp.on("session.start", async (event) => { + await getThreadMode(event.thread.id); + }); + + amp.on("agent.start", async (event, ctx) => { + let currentMode = await getThreadMode(event.thread.id); + const command = parsePonytailCommand(event.message); + let commandResult = ""; + + if (command?.type === "set-mode") { + currentMode = command.mode; + setThreadMode(event.thread.id, currentMode); + commandResult = `Ponytail mode changed to ${currentMode} for this thread.`; + } else if (command?.type === "set-default") { + await setDefaultMode(command.mode); + commandResult = + configuredDefaultMode === command.mode + ? `Default Ponytail mode set to ${command.mode}. The current thread remains ${currentMode}.` + : `Saved default ${command.mode}, but PONYTAIL_DEFAULT_MODE keeps the effective default at ${configuredDefaultMode}. The current thread remains ${currentMode}.`; + } else if (command?.type === "status") { + commandResult = `Ponytail status: current ${currentMode}; default ${configuredDefaultMode}.`; + } else if (command?.type === "invalid") { + commandResult = + "Unknown Ponytail mode. Use off, lite, full, ultra, status, or default ."; + } + + if (commandResult) await notify(commandResult, ctx.ui); + + const instructions = getPonytailInstructions(currentMode); + const content = [commandResult, instructions].filter(Boolean).join("\n\n"); + + return content ? { message: { content } } : undefined; + }); + + amp.registerCommand( + "ponytail-mode", + { + title: "Change mode", + category: "ponytail", + description: "Set Ponytail intensity for the active thread.", + }, + async (ctx) => { + if (!requireThread(ctx)) { + await ctx.ui.notify("Start or open a thread before changing its Ponytail mode."); + return; + } + + const currentMode = await getThreadMode(ctx.thread.id); + const selected = await ctx.ui.select({ + title: "Ponytail mode", + message: + "lite suggests the lazier option; full enforces the ladder; ultra challenges unnecessary work.", + options: [...PONYTAIL_MODES], + initialValue: currentMode, + }); + const mode = normalizeMode(selected); + if (!mode) return; + + setThreadMode(ctx.thread.id, mode); + await ctx.ui.notify(`Ponytail mode set to ${mode} for this thread.`); + }, + ); + + amp.registerCommand( + "ponytail-status", + { + title: "Show status", + category: "ponytail", + description: "Show the active thread mode and configured default.", + }, + async (ctx) => { + await loadConfiguration; + const current = ctx.thread ? await getThreadMode(ctx.thread.id) : "(no active thread)"; + await ctx.ui.notify(`Ponytail: current ${current}; default ${configuredDefaultMode}.`); + }, + ); + + amp.registerCommand( + "ponytail-default-mode", + { + title: "Set default mode", + category: "ponytail", + description: "Set the Ponytail mode used by new Amp threads.", + }, + async (ctx) => { + await loadConfiguration; + const selected = await ctx.ui.select({ + title: "Default Ponytail mode", + message: "This applies to new threads and is saved in Amp settings.", + options: [...PONYTAIL_MODES], + initialValue: configuredDefaultMode, + }); + const mode = normalizeMode(selected); + if (!mode) return; + + await setDefaultMode(mode); + const overridden = configuredDefaultMode !== mode; + await ctx.ui.notify( + overridden + ? `Saved ${mode}, but PONYTAIL_DEFAULT_MODE keeps the effective default at ${configuredDefaultMode}.` + : `Default Ponytail mode set to ${mode}.`, + ); + }, + ); + + amp.registerCommand( + "ponytail-help", + { + title: "Open documentation", + category: "ponytail", + description: "Open the Ponytail documentation on GitHub.", + }, + async (ctx) => { + await ctx.system.open(PONYTAIL_URL); + }, + ); +} + +/* +MIT License + +Copyright (c) 2026 DietrichGebert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ diff --git a/pyproject.toml b/pyproject.toml index 26cf86e..86e4d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,7 @@ description = "Add your description here" requires-python = ">=3.14" [dependency-groups] -dev = [ - "pyrefly>=1.1.1", - "ruff>=0.16.0", -] +dev = ["pyrefly>=1.1.1", "ruff>=0.16.0"] [tool.pyrefly] search_path = [".", "skills/multilingual-caption-video/scripts"] diff --git a/tests/ponytail-plugin.test.ts b/tests/ponytail-plugin.test.ts new file mode 100644 index 0000000..aa08750 --- /dev/null +++ b/tests/ponytail-plugin.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from "bun:test"; + +import ponytailPlugin, { + filterSkillBodyForMode, + getPonytailInstructions, + isDeactivationCommand, + parsePonytailCommand, +} from "../plugins/ponytail"; + +type Handler = (event: any, context?: any) => any; + +function createAmp(defaultMode?: string) { + const handlers = new Map(); + const commands = new Map(); + const notifications: string[] = []; + const updates: Array<[Record, string | undefined]> = []; + let configurationObserver: Handler | undefined; + + const amp = { + configuration: { + get: async () => (defaultMode ? { "ponytail.defaultMode": defaultMode } : {}), + update: async (value: Record, target?: string) => { + updates.push([value, target]); + }, + subscribe(observer: Handler) { + configurationObserver = observer; + return { unsubscribe() {} }; + }, + }, + logger: { log() {} }, + helpers: { isPluginUINotAvailableError: () => false }, + on(event: string, handler: Handler) { + handlers.set(event, handler); + return { unsubscribe() {} }; + }, + onDispose() { + return { unsubscribe() {} }; + }, + registerCommand(id: string, _options: unknown, handler: Handler) { + commands.set(id, handler); + return { unsubscribe() {}, setAvailability() {} }; + }, + }; + + ponytailPlugin(amp as any); + + return { + handlers, + commands, + notifications, + updates, + updateConfiguration(config: Record) { + configurationObserver?.(config); + }, + }; +} + +function commandContext(notifications: string[], selected?: string, threadID = "T-test") { + return { + thread: { id: threadID }, + ui: { + notify: async (message: string) => { + notifications.push(message); + }, + select: async () => selected, + }, + system: { open: async () => {} }, + }; +} + +function eventContext(notifications: string[]) { + return { + ui: { + notify: async (message: string) => { + notifications.push(message); + }, + }, + }; +} + +describe("Ponytail command parsing", () => { + test("accepts Amp and upstream command forms", () => { + expect(parsePonytailCommand("/ponytail lite")).toEqual({ + type: "set-mode", + mode: "lite", + }); + expect(parsePonytailCommand("@ponytail default ultra")).toEqual({ + type: "set-default", + mode: "ultra", + }); + expect(parsePonytailCommand("/ponytail:ponytail status")).toEqual({ + type: "status", + }); + expect(parsePonytailCommand("please use ponytail")).toBeNull(); + }); + + test("only deactivates for standalone commands", () => { + expect(isDeactivationCommand("Stop ponytail!")).toBeTrue(); + expect(isDeactivationCommand("normal mode.")).toBeTrue(); + expect(isDeactivationCommand("add a normal mode toggle")).toBeFalse(); + }); +}); + +describe("Ponytail instructions", () => { + test("keeps only the selected intensity row and example", () => { + const body = [ + "| **lite** | lite row |", + "| **full** | full row |", + '- lite: "lite example"', + '- full: "full example"', + "- Full: ordinary rule without a quote", + ].join("\n"); + + expect(filterSkillBodyForMode(body, "lite")).toBe( + [ + "| **lite** | lite row |", + '- lite: "lite example"', + "- Full: ordinary rule without a quote", + ].join("\n"), + ); + }); + + test("returns no instructions when disabled", () => { + expect(getPonytailInstructions("off")).toBe(""); + expect(getPonytailInstructions("ultra")).toContain("PONYTAIL MODE ACTIVE — level: ultra"); + }); +}); + +describe("Amp integration", () => { + test("injects the configured default on every turn", async () => { + const { handlers, notifications } = createAmp("lite"); + const agentStart = handlers.get("agent.start")!; + + const result = await agentStart( + { + thread: { id: "T-one" }, + message: "Fix the bug", + }, + eventContext(notifications), + ); + + expect(result.message.content).toContain("PONYTAIL MODE ACTIVE — level: lite"); + expect(result.message.content).not.toContain("| **full** |"); + }); + + test("keeps mode changes isolated per thread", async () => { + const { handlers, notifications } = createAmp(); + const agentStart = handlers.get("agent.start")!; + const context = eventContext(notifications); + + await agentStart( + { + thread: { id: "T-one" }, + message: "/ponytail ultra", + }, + context, + ); + const first = await agentStart( + { + thread: { id: "T-one" }, + message: "Implement it", + }, + context, + ); + const second = await agentStart( + { + thread: { id: "T-two" }, + message: "Implement it", + }, + context, + ); + + expect(first.message.content).toContain("level: ultra"); + expect(second.message.content).toContain("level: full"); + expect(notifications).toContain("Ponytail mode changed to ultra for this thread."); + }); + + test("uses live default changes for new threads", async () => { + const { handlers, notifications, updateConfiguration } = createAmp(); + const agentStart = handlers.get("agent.start")!; + + updateConfiguration({ "ponytail.defaultMode": "lite" }); + const result = await agentStart( + { + thread: { id: "T-new" }, + message: "Implement it", + }, + eventContext(notifications), + ); + + expect(result.message.content).toContain("level: lite"); + }); + + test("stops injecting after exact deactivation", async () => { + const { handlers, notifications } = createAmp(); + const agentStart = handlers.get("agent.start")!; + const context = eventContext(notifications); + + const stopped = await agentStart( + { + thread: { id: "T-one" }, + message: "stop ponytail", + }, + context, + ); + const next = await agentStart( + { + thread: { id: "T-one" }, + message: "Keep working", + }, + context, + ); + + expect(stopped.message.content).toBe("Ponytail mode changed to off for this thread."); + expect(notifications).toContain("Ponytail mode changed to off for this thread."); + expect(next).toBeUndefined(); + }); + + test("registers native mode and default controls", async () => { + const { commands, notifications, updates } = createAmp(); + + await commands.get("ponytail-mode")!(commandContext(notifications, "ultra")); + await commands.get("ponytail-default-mode")!(commandContext(notifications, "lite")); + + expect(notifications).toContain("Ponytail mode set to ultra for this thread."); + expect(updates).toEqual([[{ "ponytail.defaultMode": "lite" }, "global"]]); + }); +});