Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions crates/ponytail/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::path::PathBuf;
pub const DEFAULT_MODE: &str = "full";
pub const VALID_MODES: &[&str] = &[
"off", "lite", "full", "ultra", "review", "audit", "debt", "gain", "help", "playbook",
"no-hallucination",
];
pub const RUNTIME_MODES: &[&str] = &["off", "lite", "full", "ultra"];

Expand All @@ -30,6 +31,43 @@ pub fn normalize_extended_mode(mode: &str) -> Option<String> {
.or_else(|| crate::sub_skills::get_custom(&m).map(|_| m))
}

/// Serializes tests (in this module and `instructions.rs`) that mutate
/// process-global env vars (`CLAUDE_CONFIG_DIR`, `PONYTAIL_DEFAULT_MODE`) —
/// `cargo test` runs unit tests in parallel by default, so unguarded
/// `set_var`/`remove_var` calls can leak across assertions.
pub static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Compression/persona plugins known to conflict with ponytail's own style
/// guidance if both are active — e.g. caveman's terse-prose rules vs
/// ponytail's own output-shape rules.
const KNOWN_COMPRESSION_PLUGINS: &[&str] = &["caveman"];

fn claude_dir() -> PathBuf {
std::env::var("CLAUDE_CONFIG_DIR").map_or_else(
|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
},
PathBuf::from,
)
}

/// Scans `~/.claude/settings.json` (or `$CLAUDE_CONFIG_DIR/settings.json`)
/// for known compression/persona plugins so ponytail can add a
/// deconfliction note instead of silently contradicting them.
pub fn detect_compression_plugins() -> Vec<&'static str> {
let Ok(raw) = std::fs::read_to_string(claude_dir().join("settings.json")) else {
return Vec::new();
};
let blob = raw.to_lowercase();
KNOWN_COMPRESSION_PLUGINS
.iter()
.filter(|name| blob.contains(*name))
.copied()
.collect()
}

pub fn is_deactivation(text: &str) -> bool {
let t = text.trim().to_lowercase();
let t = t.trim_end_matches(|c: char| c == '.' || c == '!' || c == '?' || c.is_whitespace());
Expand Down Expand Up @@ -102,6 +140,26 @@ mod tests {
assert_eq!(normalize_mode("review"), None);
}

#[test]
fn no_compression_plugins_when_settings_missing() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", "/nonexistent/ponytail-test-dir") };
assert!(detect_compression_plugins().is_empty());
unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") };
}

#[test]
fn detects_caveman_in_settings_json() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("ponytail_test_compression_conflict");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("settings.json"), r#"{"plugins": ["caveman"]}"#).unwrap();
unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) };
assert_eq!(detect_compression_plugins(), vec!["caveman"]);
unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") };
std::fs::remove_dir_all(&dir).ok();
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#[test]
fn detects_deactivation() {
assert!(is_deactivation("stop ponytail"));
Expand All @@ -112,12 +170,14 @@ mod tests {

#[test]
fn defaults_to_full() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("PONYTAIL_DEFAULT_MODE") };
assert_eq!(default_mode(), "full");
}

#[test]
fn reads_env_var() {
let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("PONYTAIL_DEFAULT_MODE", "lite") };
assert_eq!(default_mode(), "lite");
unsafe { std::env::remove_var("PONYTAIL_DEFAULT_MODE") };
Expand Down
81 changes: 39 additions & 42 deletions crates/ponytail/src/instructions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,35 @@ pub fn build(mode: &str, skill_path: Option<&Path>) -> Instructions {
.unwrap_or_else(|| EMBEDDED_SKILL.to_string())
};

let filtered = filter_skill_body(&skill_body, &effective);
let mut filtered = filter_skill_body(&skill_body, &effective);
filtered.push_str(&compression_deconfliction());

Instructions {
mode: effective,
body: filtered,
}
}

/// If a known compression/persona plugin (e.g. caveman) is also wired into
/// the agent's settings, add a short note so the two don't read as
/// contradictory: ponytail governs code structure, the peer plugin governs
/// output style.
fn compression_deconfliction() -> String {
let peers = config::detect_compression_plugins();
if peers.is_empty() {
return String::new();
}
format!(
"\n\n## Compression plugin coexistence\n\n\
Detected: {}. Ponytail governs WHAT to build (the ladder, YAGNI, \
stdlib-first, minimal diffs). Defer to the other plugin for output \
STYLE (brevity, tone, formatting). If rules conflict, the \
structural rule (ponytail) wins for code decisions; the style rule \
(peer plugin) wins for prose and formatting.",
peers.join(", ")
)
}

pub fn filter_skill_body(body: &str, mode: &str) -> String {
let effective = config::normalize_mode(mode).unwrap_or(config::DEFAULT_MODE);
body.lines()
Expand All @@ -118,58 +139,34 @@ pub fn filter_skill_body(body: &str, mode: &str) -> String {
.join("\n")
}

#[allow(dead_code)]
pub fn fallback_instructions(mode: &str) -> String {
let m = config::normalize_mode(mode).unwrap_or(config::DEFAULT_MODE);
format!(
"PONYTAIL MODE ACTIVE — level: {m}\n\n\
You are a lazy senior developer. Lazy means efficient, not careless — \
less work for the same result. The best code is the code never written.\n\n\
## The ladder\n\n\
1. Does this need to exist at all? (YAGNI)\n\
2. Already in this codebase? Reuse it.\n\
3. Stdlib does it? Use it.\n\
4. Native platform feature covers it? Use it.\n\
5. Already-installed dependency solves it? Use it.\n\
6. Can it be one line? One line.\n\
7. Only then: the minimum code that works.\n\n\
## Rules\n\n\
No unrequested abstractions. No boilerplate. Deletion over addition.\n\
Code first, then at most three lines: what was skipped, when to add it.\n\
Never simplify away: input validation, error handling, security, accessibility.\n\n\
NEVER invent APIs, functions, or variables that don't exist in the codebase.\n\
Always verify the API surface before using it — read the file or docs first.\n\
Prefer searching the codebase over assuming. Trust but verify.\n\n\
## Persona boundary\n\n\
Act the role, never label it. Don't mention ponytail mode, intensity\n\
levels, or persona names in replies. The user knows what they asked for.\n\n\
## Simplification markers\n\n\
Mark deliberate shortcuts with a `ponytail:` comment. One line only:\n\
`ponytail: <what was skipped>, add when <condition>`\n\
If the explanation is longer than the code, delete the explanation.",
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn fallback_generates_for_mode() {
let f = fallback_instructions("full");
assert!(f.contains("PONYTAIL MODE ACTIVE"));
assert!(f.contains("The ladder"));
assert!(f.contains("Persona boundary"));
assert!(f.contains("Simplification markers"));
}

#[test]
fn build_uses_embedded_skill() {
let ins = build("full", None);
assert!(!ins.body.is_empty());
assert_eq!(ins.mode, "full");
}

#[test]
#[allow(unsafe_code)]
fn build_appends_deconfliction_when_compression_plugin_present() {
let _guard = config::ENV_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join("ponytail_test_instructions_compression");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("settings.json"), r#"{"plugins": ["caveman"]}"#).unwrap();
unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) };

let ins = build("full", None);
assert!(ins.body.contains("Compression plugin coexistence"));
assert!(ins.body.contains("caveman"));

unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") };
std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn filter_keeps_non_mode_lines() {
let input = "some rule\n| **lite** | lite only |\n| **full** | full only |\nother rule";
Expand Down
3 changes: 2 additions & 1 deletion crates/ponytail/src/skill-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ Level sticks until changed or session end.
| **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. |
| **ponytail-no-hallucination** | `/ponytail-no-hallucination` | Reality-check layer: blocks invented APIs, deprecated methods, undeclared variables. |

Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
and OpenCode use the slash-command forms above (OpenCode ships all six as
and OpenCode use the slash-command forms above (OpenCode ships all seven as
slash commands).

## Deactivate
Expand Down
35 changes: 35 additions & 0 deletions crates/ponytail/src/skill-no-hallucination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
name: ponytail-no-hallucination
description: >
Reality-check companion to ponytail. Blocks invented APIs, deprecated
methods, framework confusion, and undeclared variables — a minimal-looking
line that calls a function which doesn't exist is not lazy, it's a bug with
extra confidence. Use whenever the user says "ponytail-no-hallucination",
"/ponytail-no-hallucination", "no hallucinations", "verify APIs", or "don't
invent functions".
---

# Ponytail — No Hallucination Layer

The true lazy path is to use only what is provably there. A one-liner that
calls a function which doesn't exist isn't minimal, it's a confident bug.

## The only rule

Before writing a function call, import, or method access, answer: **does
this exist in the version the user is running?** "Probably" isn't an
answer — stop and check the file, the docs, or the installed dependency
version before using it.

## What this blocks

- **Made-up methods** — functions that don't exist in the library being used.
- **Framework confusion** — e.g. Flask's `render_template` in a Django
codebase, or `req.isAuthenticated()` without Passport installed.
- **Deprecated APIs** — `new Buffer()`, `ReactDOM.render()`, and similar.
- **Undeclared variables** — names referenced but never imported or defined.

Trust but verify: read the file or docs first, prefer searching the
codebase over assuming the API surface.

"stop ponytail-no-hallucination" or "normal mode" to revert.
3 changes: 2 additions & 1 deletion crates/ponytail/src/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ every sibling caller still broken. Fix it once, where all callers route through.
- Fewest files possible. Shortest working diff wins — but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`. Normal or trivial code gets no comment.
- Do not expose the ponytail/lazy persona or other meta framing in user-facing replies — apply the behavior silently unless the user explicitly asks about it. Don't coach the user to "think lazily"; propose the minimal solution directly in plain engineering language.

## Output

Expand Down
2 changes: 2 additions & 0 deletions crates/ponytail/src/sub_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub const SKILL_DEBT: &str = include_str!("skill-debt.md");
pub const SKILL_GAIN: &str = include_str!("skill-gain.md");
pub const SKILL_HELP: &str = include_str!("skill-help.md");
pub const SKILL_PLAYBOOK: &str = include_str!("skill-playbook.md");
pub const SKILL_NO_HALLUCINATION: &str = include_str!("skill-no-hallucination.md");

pub fn get(name: &str) -> Option<&'static str> {
match name {
Expand All @@ -16,6 +17,7 @@ pub fn get(name: &str) -> Option<&'static str> {
"gain" => Some(SKILL_GAIN),
"help" => Some(SKILL_HELP),
"playbook" => Some(SKILL_PLAYBOOK),
"no-hallucination" => Some(SKILL_NO_HALLUCINATION),
_ => None,
}
}
Expand Down
15 changes: 14 additions & 1 deletion crates/ponytail/src/switcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@ pub enum SwitchAction {
}

fn all_skill_names() -> Vec<String> {
let mut names: Vec<String> = ["review", "audit", "debt", "gain", "help", "playbook"]
let mut names: Vec<String> = [
"review",
"audit",
"debt",
"gain",
"help",
"playbook",
"no-hallucination",
]
.iter()
.map(|s| s.to_string())
.collect();
Expand Down Expand Up @@ -143,6 +151,11 @@ mod tests {
assert!(matches!(detect("/ponytail-playbook"), Some(SwitchAction::SetMode(m)) if m == "playbook"));
}

#[test]
fn detects_sub_skill_no_hallucination() {
assert!(matches!(detect("/ponytail-no-hallucination"), Some(SwitchAction::SetMode(m)) if m == "no-hallucination"));
}

#[test]
fn detects_session_mode() {
assert!(matches!(detect("/ponytail session ultra"), Some(SwitchAction::SetSession(m)) if m == "ultra"));
Expand Down
40 changes: 39 additions & 1 deletion src/cli/ponytail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ pub struct PonytailArgs {
pub action: PonytailAction,
}

fn report_message(mode: &str) -> String {
if mode == "off" {
"ponytail is off. Use /ponytail lite|full|ultra to activate.".to_string()
} else {
format!("PONYTAIL MODE ACTIVE — level: {mode}")
}
}

fn emit_hook(event: &str, off_guard: bool) {
let mode = ponytail::active_mode().unwrap_or_else(ponytail::default_mode);
if off_guard && mode == "off" {
Expand Down Expand Up @@ -190,7 +198,19 @@ impl PonytailArgs {
ponytail::SwitchAction::Off => {
ponytail::clear_active();
}
ponytail::SwitchAction::Report => {}
ponytail::SwitchAction::Report => {
let mode = ponytail::active_mode()
.unwrap_or_else(ponytail::default_mode);
let platform = ponytail::detect_platform();
let ctx = report_message(&mode);
let output = ponytail::format_hook_output(
"UserPromptSubmit",
&ctx,
&platform,
);
println!("{output}");
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
println!("OK");
Expand All @@ -212,3 +232,21 @@ impl PonytailArgs {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn report_message_says_active_for_runtime_mode() {
assert_eq!(report_message("full"), "PONYTAIL MODE ACTIVE — level: full");
}

#[test]
fn report_message_says_off_for_off_mode() {
assert_eq!(
report_message("off"),
"ponytail is off. Use /ponytail lite|full|ultra to activate."
);
}
}
14 changes: 11 additions & 3 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,17 @@ pub fn load() -> State {
}

pub fn save(state: &State) {
let _ = fs::create_dir_all(state_dir());
if let Ok(json) = serde_json::to_string_pretty(state) {
let _ = fs::write(state_path(), json + "\n");
if let Err(e) = fs::create_dir_all(state_dir()) {
eprintln!("[agentflare] warning: failed to create state dir: {e}");
return;
}
match serde_json::to_string_pretty(state) {
Ok(json) => {
if let Err(e) = fs::write(state_path(), json + "\n") {
eprintln!("[agentflare] warning: failed to persist state: {e}");
}
}
Err(e) => eprintln!("[agentflare] warning: failed to serialize state: {e}"),
}
}

Expand Down
Loading