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
2 changes: 1 addition & 1 deletion src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" => {
Expand Down
131 changes: 130 additions & 1 deletion src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
}
}
Comment on lines +142 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'use\s+std::io' src/init.rs

Repository: getappz/agentflare

Length of output: 156


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== src/init.rs outline ==\n'
ast-grep outline src/init.rs --view expanded || true

printf '\n== imports and prompt_yes region ==\n'
cat -n src/init.rs | sed -n '1,220p'

Repository: getappz/agentflare

Length of output: 11434


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== flush usage in repo ==\n'
rg -n 'stdout\(\)\.flush|flush\(\)' src . || true

printf '\n== std::io::Write imports in repo ==\n'
rg -n 'use\s+std::io::Write|use\s+std::io::.*Write' src || true

printf '\n== prompt-like helpers around init.rs ==\n'
cat -n src/init.rs | sed -n '1,90p'

Repository: getappz/agentflare

Length of output: 4443


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
text = Path('src/init.rs').read_text()
start = text.index('fn prompt_yes')
end = text.index('fn confirm_rule_refresh')
snippet = text[start:end]
print(snippet)
print("HAS_STD_IO_WRITE_IMPORT:", 'use std::io::Write' in text or 'use std::io::{Write' in text)
print("HAS_FLUSH_CALL:", '.flush()' in text)
PY

Repository: getappz/agentflare

Length of output: 1159


Flush the prompt and fail closed on stdin errors print!("{message}") needs an explicit flush before read_line, and a read error should return false instead of falling through to the empty-input true branch. Add use std::io::Write; if you wire in flush().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/init.rs` around lines 142 - 160, The prompt handling in prompt_yes
currently writes without flushing and treats stdin read errors as empty input,
which can incorrectly return true. Update prompt_yes to flush stdout immediately
after print!("{message}") by bringing std::io::Write into scope, and make the
read_line error path fail closed by returning false (with the skipped message)
instead of falling through to the "" match arm.


/// 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, &current) {
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)() {
Expand Down Expand Up @@ -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(|| {
Expand Down
68 changes: 64 additions & 4 deletions src/rule_text.rs
Original file line number Diff line number Diff line change
@@ -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,
_ => &[],
}
}
Loading