diff --git a/crates/ponytail/src/config.rs b/crates/ponytail/src/config.rs index 340d3eb4..ea73a648 100644 --- a/crates/ponytail/src/config.rs +++ b/crates/ponytail/src/config.rs @@ -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"]; @@ -30,6 +31,43 @@ pub fn normalize_extended_mode(mode: &str) -> Option { .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()); @@ -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(); + } + #[test] fn detects_deactivation() { assert!(is_deactivation("stop ponytail")); @@ -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") }; diff --git a/crates/ponytail/src/instructions.rs b/crates/ponytail/src/instructions.rs index 8d15c0f6..ff8b6af4 100644 --- a/crates/ponytail/src/instructions.rs +++ b/crates/ponytail/src/instructions.rs @@ -84,7 +84,8 @@ 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, @@ -92,6 +93,26 @@ pub fn build(mode: &str, skill_path: Option<&Path>) -> Instructions { } } +/// 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() @@ -118,51 +139,10 @@ 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: , add when `\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); @@ -170,6 +150,23 @@ mod tests { 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"; diff --git a/crates/ponytail/src/skill-help.md b/crates/ponytail/src/skill-help.md index 21bab1c5..afe674db 100644 --- a/crates/ponytail/src/skill-help.md +++ b/crates/ponytail/src/skill-help.md @@ -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 diff --git a/crates/ponytail/src/skill-no-hallucination.md b/crates/ponytail/src/skill-no-hallucination.md new file mode 100644 index 00000000..399c65ab --- /dev/null +++ b/crates/ponytail/src/skill-no-hallucination.md @@ -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. diff --git a/crates/ponytail/src/skill.md b/crates/ponytail/src/skill.md index 5f999d78..6910f7ee 100644 --- a/crates/ponytail/src/skill.md +++ b/crates/ponytail/src/skill.md @@ -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 diff --git a/crates/ponytail/src/sub_skills.rs b/crates/ponytail/src/sub_skills.rs index 24f89543..eaea23ce 100644 --- a/crates/ponytail/src/sub_skills.rs +++ b/crates/ponytail/src/sub_skills.rs @@ -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 { @@ -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, } } diff --git a/crates/ponytail/src/switcher.rs b/crates/ponytail/src/switcher.rs index 3f17b47e..6d73f159 100644 --- a/crates/ponytail/src/switcher.rs +++ b/crates/ponytail/src/switcher.rs @@ -10,7 +10,15 @@ pub enum SwitchAction { } fn all_skill_names() -> Vec { - let mut names: Vec = ["review", "audit", "debt", "gain", "help", "playbook"] + let mut names: Vec = [ + "review", + "audit", + "debt", + "gain", + "help", + "playbook", + "no-hallucination", + ] .iter() .map(|s| s.to_string()) .collect(); @@ -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")); diff --git a/src/cli/ponytail.rs b/src/cli/ponytail.rs index 674e4223..481492e5 100644 --- a/src/cli/ponytail.rs +++ b/src/cli/ponytail.rs @@ -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" { @@ -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; + } } } println!("OK"); @@ -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." + ); + } +} diff --git a/src/state.rs b/src/state.rs index 9de42219..dcf76087 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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}"), } }