diff --git a/src/components.rs b/src/components.rs index 770458cc..a2633896 100644 --- a/src/components.rs +++ b/src/components.rs @@ -157,7 +157,7 @@ fn write_if_absent(path: &PathBuf, content: &str) -> bool { /// get project-local files instead, and only when absent, since a project /// file is more sensitive to clobber than a per-user dotfile. Continue has /// no dedicated rules convention (per research), so it gets none. -fn rule_targets(host: &str) -> Vec<(PathBuf, String)> { +pub(crate) fn rule_targets(host: &str) -> Vec<(PathBuf, String)> { let joined = || rule_text::all().join("\n\n"); match host { "claude-code" => { diff --git a/src/init.rs b/src/init.rs index fab8dc19..9960e423 100644 --- a/src/init.rs +++ b/src/init.rs @@ -5,8 +5,9 @@ // written without going through a plugin marketplace (Claude Code, Cursor). // Codex's hook only activates through its plugin system, so that wiring // lives in .codex-plugin/ instead, not here. -use crate::components::get_components; +use crate::components::{get_components, rule_targets}; use crate::paths::home; +use crate::rule_text; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; @@ -123,10 +124,70 @@ fn has_ponytail_ref(content: &str) -> bool { content.to_lowercase().contains("ponytail") } +/// A rule file is stale (safe to offer a refresh) only if its on-disk +/// content matches a KNOWN old version verbatim — anything else (already +/// current, or diverging for some other reason) is left untouched, since +/// that "some other reason" is most likely a user edit. +fn is_stale_rule(path: &PathBuf, current: &str) -> bool { + let Some(filename) = path.file_name().and_then(|f| f.to_str()) else { return false }; + let superseded = rule_text::superseded(filename); + if superseded.is_empty() { + return false; + } + let Ok(existing) = fs::read_to_string(path) else { return false }; + existing.trim_end() != current.trim_end() + && superseded.iter().any(|old| existing.trim_end() == old.trim_end()) +} + +fn prompt_yes(message: &str, agent: &str, yes: bool) -> bool { + if yes { + return true; + } + print!("{message}"); + let mut input = String::new(); + let bytes_read = std::io::stdin().read_line(&mut input).ok(); + if bytes_read == Some(0) { + println!(" Skipped. Re-run: agentflare init --agent {agent}"); + return false; + } + match input.trim().to_lowercase().as_str() { + "y" | "yes" | "" => true, + _ => { + println!(" Skipped. Re-run: agentflare init --agent {agent}"); + false + } + } +} + +/// Rule files under `rule_targets` are only ever written when absent (see +/// components.rs's "rules" component) — safe by default, but it means a rule +/// whose wording we later fix (e.g. the engram gateway-discovery bug fixed +/// 2026-07-09) stays stale forever on machines that already have the old +/// file. Offer to refresh it, same consent pattern as ponytail migration. +fn confirm_rule_refresh(agent: &str, yes: bool) { + for (path, current) in rule_targets(agent) { + if !is_stale_rule(&path, ¤t) { + continue; + } + + println!(); + println!("⚠ {} has outdated guidance (from an earlier agentflare version).", path.display()); + if !prompt_yes(" Refresh to the current version? [Y/n] ", agent, yes) { + continue; + } + + match fs::write(&path, format!("{current}\n")) { + Ok(_) => println!(" ok {} refreshed", path.display()), + Err(e) => println!(" fail writing {}: {e}", path.display()), + } + } +} + pub fn run(agent: &str, yes: bool) { println!("agentflare init --agent {agent}\n"); check_competing_plugins(agent); + confirm_rule_refresh(agent, yes); for c in get_components(agent) { if (c.check)() { @@ -448,6 +509,74 @@ mod tests { use super::*; use crate::paths::test_support::{with_temp_cwd, with_temp_home}; + #[test] + fn is_stale_rule_true_for_known_superseded_content() { + with_temp_home(|| { + let path = home().join(".claude").join("rules").join("engram.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{}\n", rule_text::ENGRAM_SUPERSEDED[0])).unwrap(); + assert!(is_stale_rule(&path, rule_text::ENGRAM)); + }); + } + + #[test] + fn is_stale_rule_false_when_already_current() { + with_temp_home(|| { + let path = home().join(".claude").join("rules").join("engram.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{}\n", rule_text::ENGRAM)).unwrap(); + assert!(!is_stale_rule(&path, rule_text::ENGRAM)); + }); + } + + #[test] + fn is_stale_rule_false_for_user_edited_content() { + with_temp_home(|| { + let path = home().join(".claude").join("rules").join("engram.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "my own custom engram notes\n").unwrap(); + assert!(!is_stale_rule(&path, rule_text::ENGRAM)); + }); + } + + #[test] + fn is_stale_rule_false_for_rule_with_no_superseded_versions() { + with_temp_home(|| { + let path = home().join(".claude").join("rules").join("git.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "some old git rule text\n").unwrap(); + assert!(!is_stale_rule(&path, rule_text::GIT)); + }); + } + + #[test] + fn confirm_rule_refresh_updates_stale_file_when_yes() { + with_temp_home(|| { + let path = home().join(".claude").join("rules").join("engram.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{}\n", rule_text::ENGRAM_SUPERSEDED[0])).unwrap(); + + confirm_rule_refresh("claude-code", true); + + let content = fs::read_to_string(&path).unwrap(); + assert_eq!(content.trim_end(), rule_text::ENGRAM); + }); + } + + #[test] + fn confirm_rule_refresh_leaves_user_edited_file_alone() { + with_temp_home(|| { + let path = home().join(".claude").join("rules").join("engram.md"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "my own custom engram notes\n").unwrap(); + + confirm_rule_refresh("claude-code", true); + + let content = fs::read_to_string(&path).unwrap(); + assert_eq!(content.trim_end(), "my own custom engram notes"); + }); + } + #[test] fn wire_claude_code_writes_hooks_to_fresh_settings() { with_temp_home(|| { diff --git a/src/rule_text.rs b/src/rule_text.rs index 4396abe3..4b92b2d4 100644 --- a/src/rule_text.rs +++ b/src/rule_text.rs @@ -1,16 +1,76 @@ // Shared rule copy — used by components.rs (per-host rule files) and could be // reused by anything else that needs the same wording. One place to edit it. -pub const EXA: &str = "Use Exa MCP tools (web_search_exa, get_code_context_exa, company_research_exa) for internet search. Skip WebFetch/WebSearch/websearch-agent — Exa covers it for every session and subagent."; +// Tag vocabulary, shared across all rules below — kept small and consistent +// rather than inventing new tags per rule: +// @use primary tool/resource to reach for +// @skip what NOT to use instead +// @when trigger/timing condition +// @how mechanism, only when it's not obvious from @use +// @rule hard constraint / format requirement +// @scope where this applies (session/subagent breadth) +pub const EXA: &str = r#"@use: Exa MCP tools — web_search_exa, get_code_context_exa, company_research_exa +@when: internet search +@skip: WebFetch, WebSearch, websearch-agent +@scope: every session + subagent"#; -pub const GIT: &str = "Commit messages are the message only: no \"Generated with Claude Code\", no Co-Authored-By trailer. `git commit -m \"...\"` format."; +pub const EXA_SUPERSEDED: &[&str] = &[ + "Use Exa MCP tools (web_search_exa, get_code_context_exa, company_research_exa) for internet search. Skip WebFetch/WebSearch/websearch-agent — Exa covers it for every session and subagent.", +]; -pub const LEANCTX: &str = "Prefer lean-ctx over native tools: ctx_read > Read/cat, ctx_shell > Bash, ctx_search > Grep, ctx_glob > Glob. Orient with ctx_compose before exploring unfamiliar code — one call instead of a search-read-search chain. ctx_callgraph answers \"who calls X\", not grep. Same rule for every subagent."; +pub const GIT: &str = r#"@rule: commit message = message only +@skip: "Generated with Claude Code" line, Co-Authored-By trailer +@how: git commit -m "...""#; + +pub const GIT_SUPERSEDED: &[&str] = &[ + "Commit messages are the message only: no \"Generated with Claude Code\", no Co-Authored-By trailer. `git commit -m \"...\"` format.", +]; + +pub const LEANCTX: &str = r#"@use: lean-ctx over native tools — ctx_read>Read/cat, ctx_shell>Bash, ctx_search>Grep, ctx_glob>Glob, ctx_callgraph>grep for "who calls X" +@when: unfamiliar code — ctx_compose FIRST, one call vs search→read→search chain +@scope: every subagent"#; + +pub const LEANCTX_SUPERSEDED: &[&str] = &[ + "Prefer lean-ctx over native tools: ctx_read > Read/cat, ctx_shell > Bash, ctx_search > Grep, ctx_glob > Glob. Orient with ctx_compose before exploring unfamiliar code — one call instead of a search-read-search chain. ctx_callgraph answers \"who calls X\", not grep. Same rule for every subagent.", +]; // Workflow-level, not tool-name-level: engram's exposed MCP tool names have // shifted across versions, so pin the behavior, not the exact call names. -pub const ENGRAM: &str = "Use engram MCP tools for persistent cross-session memory: recall relevant prior context at the start of a session, store durable decisions/facts/preferences as you learn them (not every detail — the load-bearing ones), and create a session handoff before a long session ends or context gets tight. This is the single source of truth for cross-session memory — do not duplicate it into lean-ctx's own session/knowledge tools."; +// Also don't assume a fixed access path: engram may be a native plugin +// (mcp__engram__*) or, when that's disabled to avoid duplicating agentflare's +// own gateway-registry, only reachable via gateway_search/gateway_execute. +// Absence of mcp__engram__* in ToolSearch does NOT mean engram is unavailable. +pub const ENGRAM: &str = r#"@use: engram for persistent cross-session memory +@when: session start -> recall prior context; learning -> store durable decisions/facts/prefs (load-bearing only); session end/context-tight -> create handoff +@how: direct mcp__engram__* or gateway-only — gateway_search(query) -> gateway_execute(server="engram", tool, args) — if native plugin disabled; try gateway_search before assuming unavailable +@rule: single source of truth for cross-session memory, don't duplicate into lean-ctx's session/knowledge tools +@rule: intent-first discovery applies to any gateway-fronted tool, not just engram"#; + +// Prior wording of ENGRAM, kept so `init` can detect an on-disk rule file +// that still has old text (vs. one a user hand-edited) and offer to refresh +// it with consent, the same way `confirm_ponytail_migration` asks before +// touching an existing install. Two generations: the original (pre-gateway- +// discovery-fix) wording, and the fixed-but-uncompressed wording that shipped +// briefly in the same PR before the token-compression pass below. +pub const ENGRAM_SUPERSEDED: &[&str] = &[ + "Use engram MCP tools for persistent cross-session memory: recall relevant prior context at the start of a session, store durable decisions/facts/preferences as you learn them (not every detail — the load-bearing ones), and create a session handoff before a long session ends or context gets tight. This is the single source of truth for cross-session memory — do not duplicate it into lean-ctx's own session/knowledge tools.", + "Use engram for persistent cross-session memory: recall relevant prior context at the start of a session, store durable decisions/facts/preferences as you learn them (not every detail — the load-bearing ones), and create a session handoff before a long session ends or context gets tight. This is the single source of truth for cross-session memory — do not duplicate it into lean-ctx's own session/knowledge tools. Its tools may be exposed directly as mcp__engram__* or only via the agentflare gateway (gateway_search(query) -> gateway_execute(server=\"engram\", tool, args)) if the native plugin is disabled — try gateway_search before concluding engram isn't available. This intent-first discovery applies to any gateway-fronted tool, not just engram.", +]; pub fn all() -> Vec<&'static str> { vec![EXA, GIT, LEANCTX, ENGRAM] } + +/// Known-old wording for a rule file, keyed by its filename — empty for rules +/// that have never changed. Used to tell "this file still has text we shipped +/// before" (safe to offer a refresh) apart from "the user edited this" (leave +/// it alone). +pub fn superseded(filename: &str) -> &'static [&'static str] { + match filename { + "exa.md" => EXA_SUPERSEDED, + "git.md" => GIT_SUPERSEDED, + "lean-ctx.md" => LEANCTX_SUPERSEDED, + "engram.md" => ENGRAM_SUPERSEDED, + _ => &[], + } +}