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
81 changes: 78 additions & 3 deletions src/cli/vent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ pub enum VentCmd {
},
/// Triage buffered vents now (also run automatically once per turn).
Consolidate,
/// List (or file, with --title/--body) pending agentflare-core vents as
/// ONE batched GitHub issue on getappz/agentflare.
File {
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
},
/// List triaged vents for this repo's project.
List {
#[arg(long)]
Expand All @@ -33,9 +41,11 @@ pub fn run(args: VentArgs) {
tags,
} => {
let severity = crate::vent::classify::normalize_severity(Some(&severity));
let log = crate::vent::paths::log_path();
match crate::vent::capture::append(&log, None, severity, &tags, message.trim()) {
Ok(id) => println!("vented {id}"),
match crate::vent::capture::append_routed(None, severity, &tags, message.trim()) {
Ok((id, true)) => {
println!("vent {id} suppressed (same friction already vented recently)")
}
Ok((id, false)) => println!("vented {id}"),
Err(e) => eprintln!("vent failed: {e}"),
}
}
Expand All @@ -58,6 +68,49 @@ pub fn run(args: VentArgs) {
println!(" filed item {id}");
}
}
VentCmd::File { title, body } => {
let log = crate::vent::paths::global_log_path();
let filed = crate::vent::paths::global_filed_path();
let batch =
crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp());
if batch.is_empty() {
println!("nothing to file");
return;
}
let (title, body) = match (title, body) {
(Some(title), Some(body)) => (title, body),
(None, None) => {
println!("{} pending agentflare-core vent(s):", batch.len());
for g in &batch {
println!(" [{}] seen×{} {}", g.severity, g.seen_count, g.message);
}
println!("re-run with --title and --body to file these as one GitHub issue");
return;
}
(title, body) => {
eprintln!(
"vent file: need both --title and --body to file (got title={}, body={})",
title.is_some(),
body.is_some()
);
return;
}
};
match crate::vent::file::create_github_issue(&title, &body) {
Ok(url) => {
let keys: Vec<String> = batch.iter().map(|g| g.topic_key.clone()).collect();
let now = chrono::Utc::now().timestamp();
match crate::vent::file::mark_filed(&filed, &keys, &url, now) {
Ok(()) => println!("filed {url} ({} vent(s))", keys.len()),
Err(e) => eprintln!(
"filed {url} but failed to record state ({e}) — re-filing will \
create a duplicate issue until this is reconciled by hand"
),
}
}
Err(e) => eprintln!("gh issue create failed: {e}"),
}
}
VentCmd::List { actionable } => {
let conn = match agentflare_backend::db::open_db(&crate::vent::paths::backend_db_path())
{
Expand Down Expand Up @@ -104,6 +157,28 @@ pub fn run(args: VentArgs) {

#[cfg(test)]
mod tests {
#[test]
fn file_command_reports_nothing_when_batch_is_empty() {
let dir = tempfile::tempdir().unwrap();
let (log, filed) = (dir.path().join("v.jsonl"), dir.path().join("v.filed.json"));
let batch = crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp());
assert!(batch.is_empty(), "no log file yet means nothing pending");
}

#[test]
fn say_via_append_routed_marks_agentflare_core_message_as_suppressed_on_repeat() {
crate::paths::test_support::with_temp_home(|| {
let (_, first) =
crate::vent::capture::append_routed(None, "high", &[], "af-guard blocked a push")
.unwrap();
let (_, second) =
crate::vent::capture::append_routed(None, "high", &[], "af-guard blocked a push")
.unwrap();
assert!(!first);
assert!(second);
});
}

#[test]
fn append_then_read_back_roundtrips() {
let dir = tempfile::tempdir().unwrap();
Expand Down
61 changes: 61 additions & 0 deletions src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,32 @@ fn add_hook_entry(
true
}

/// Adds one prompt-type hook entry for `event` unless an entry containing
/// `marker` is already present. Same idempotency pattern as `add_hook_entry`
/// but for `"type": "prompt"` hooks (used for PostToolUseFailure judge).
fn add_prompt_hook_entry(
hooks_obj: &mut Map<String, Value>,
event: &str,
marker: &str,
matcher: &str,
prompt: &str,
timeout: u64,
) -> bool {
let arr = hooks_obj
.entry(event)
.or_insert_with(|| json!([]))
.as_array_mut()
.unwrap();
if arr.iter().any(|v| v.to_string().contains(marker)) {
return false;
}
arr.push(json!({
"matcher": matcher,
"hooks": [{ "type": "prompt", "prompt": prompt, "timeout": timeout, "continueOnBlock": true }]
}));
true
}

fn wire_claude_code() {
let path = claude_settings_path();
let mut settings = read_json_object(&path, || json!({}));
Expand Down Expand Up @@ -236,6 +262,14 @@ fn wire_claude_code() {
format!("\"{bin}\" hook pre-compact"),
5,
);
added |= add_prompt_hook_entry(
hooks_obj,
"PostToolUseFailure",
"genuine FRICTION",
"Bash|Edit|Write",
crate::rule_text::VENT_JUDGE_PROMPT,
15,
);

if !added {
ui::skip("~/.claude/settings.json hooks (already wired)");
Expand Down Expand Up @@ -695,6 +729,7 @@ mod tests {
assert!(content.contains("UserPromptSubmit"));
assert!(content.contains("PreToolUse"));
assert!(content.contains("PreCompact"));
assert!(content.contains("PostToolUseFailure"));
});
}

Expand Down Expand Up @@ -749,6 +784,32 @@ mod tests {
});
}

#[test]
fn wire_claude_code_wires_post_tool_use_failure_judge_prompt() {
with_temp_home(|| {
wire_claude_code();
let content = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap();
assert!(
content.contains("genuine FRICTION"),
"expected the judge-prompt marker in PostToolUseFailure hooks"
);
});
}

#[test]
fn wire_claude_code_post_tool_use_failure_is_idempotent() {
with_temp_home(|| {
wire_claude_code();
let first = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap();
wire_claude_code();
let second = fs::read_to_string(home().join(".claude").join("settings.json")).unwrap();
assert_eq!(
first, second,
"second run must not duplicate PostToolUseFailure"
);
});
}

#[test]
fn wire_claude_code_backfills_pre_tool_use_into_already_wired_install() {
with_temp_home(|| {
Expand Down
21 changes: 21 additions & 0 deletions src/ipc/process.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::time::Duration;

pub fn is_alive(pid: u32) -> bool {
#[cfg(windows)]
{
Expand Down Expand Up @@ -149,6 +151,25 @@ fn parse_other_pids(raw: &str, self_pid: u32) -> Vec<u32> {
pids
}

pub fn run_with_timeout<F, T>(f: F, timeout: Duration) -> Result<T, String>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
{
let handle = std::thread::spawn(f);
let deadline = std::time::Instant::now() + timeout;
loop {
if handle.is_finished() {
return Ok(handle.join().unwrap());
}
if std::time::Instant::now() >= deadline {
return Err("timed out".to_string());
}
std::thread::sleep(Duration::from_millis(50));
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
67 changes: 61 additions & 6 deletions src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1185,12 +1185,67 @@ impl AgentflareMcp {
}
let severity = crate::vent::classify::normalize_severity(req.severity.as_deref());
let tags = req.tags.unwrap_or_default();
let log = crate::vent::paths::log_path();
let event_id =
crate::vent::capture::append(&log, None, severity, &tags, req.message.trim()).map_err(
|e| ErrorData::internal_error(format!("vent capture failed: {e}"), None),
)?;
Ok(serde_json::json!({ "ok": true, "event_id": event_id }).to_string())
let (event_id, suppressed) =
crate::vent::capture::append_routed(None, severity, &tags, req.message.trim())
.map_err(|e| {
ErrorData::internal_error(format!("vent capture failed: {e}"), None)
})?;
Ok(
serde_json::json!({ "ok": true, "event_id": event_id, "suppressed": suppressed })
.to_string(),
)
}

#[tool(
description = "List or file agentflare's own tooling bugs (git shim, af-guard, item-tracker friction -- NOT this project's own vents) as ONE batched GitHub issue on getappz/agentflare. Call without title/body to see the pending batch and write a summary yourself; call again with both to file it."
)]
fn vent_file(&self, Parameters(req): Parameters<VentFileRequest>) -> Result<String, ErrorData> {
let log = crate::vent::paths::global_log_path();
let filed = crate::vent::paths::global_filed_path();
let batch = crate::vent::file::pending_batch(&log, &filed, chrono::Utc::now().timestamp());
if batch.is_empty() {
return Ok(serde_json::json!({ "ok": true, "pending": [] }).to_string());
}
match (req.title, req.body) {
(Some(title), Some(body)) => {
let url = crate::vent::file::create_github_issue(&title, &body).map_err(|e| {
ErrorData::internal_error(format!("gh issue create failed: {e}"), None)
})?;
Comment on lines +1210 to +1213

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 | 🟠 Major | 🏗️ Heavy lift

Bound the gh subprocess execution.

create_github_issue synchronously waits for gh with no deadline. A stalled network or credential flow can block this MCP request indefinitely; run it with a timeout and terminate it on expiry.

🤖 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/mcp_server.rs` around lines 1205 - 1208, Update create_github_issue and
its call from the title/body match to enforce a finite deadline for the gh
subprocess, terminating the child process when the timeout expires and
propagating a clear internal error through the existing map_err path. Preserve
successful issue creation and the current error context for ordinary command
failures.

let keys: Vec<String> = batch.iter().map(|g| g.topic_key.clone()).collect();
let now = chrono::Utc::now().timestamp();
// The issue itself is already created at this point — even if
// mark_filed fails, the caller must still learn the issue_url
// (not just an opaque error), or the same batch gets re-filed
// as a duplicate issue on the next call.
match crate::vent::file::mark_filed(&filed, &keys, &url, now) {
Ok(()) => Ok(serde_json::json!({
"ok": true,
"issue_url": url,
"filed_count": keys.len(),
})
.to_string()),
Err(e) => Ok(serde_json::json!({
"ok": true,
"issue_url": url,
"filed_count": keys.len(),
"warning": format!(
"issue created but failed to record filed-state ({e}) — re-filing \
will create a duplicate issue until this is reconciled by hand"
),
})
.to_string()),
}
}
(None, None) => Ok(serde_json::json!({ "ok": true, "pending": batch }).to_string()),
(title, body) => Err(ErrorData::invalid_params(
format!(
"vent_file needs both title and body to file (got title={}, body={}), or neither to list pending",
title.is_some(),
body.is_some()
),
None,
)),
}
}

/// Rejects PR titles that don't start with a conventional-commit type,
Expand Down
8 changes: 8 additions & 0 deletions src/mcp_server/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,14 @@ pub(crate) struct VentRequest {
pub(crate) tags: Option<Vec<String>>,
}

#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub(crate) struct VentFileRequest {
#[serde(default)]
pub(crate) title: Option<String>,
#[serde(default)]
pub(crate) body: Option<String>,
Comment on lines +329 to +334

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject half-specified file requests.

With both fields independently optional, src/mcp_server.rs Lines 1197-1219 treats {title: ...} or {body: ...} as a preview request and silently ignores the supplied value. Keep the no-fields preview case, but reject XOR input as invalid parameters.

🤖 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/mcp_server/types.rs` around lines 329 - 334, Update VentFileRequest and
its handling in the mcp_server request flow to distinguish three states: no
title and no body remains a valid preview request, both fields remain valid for
file creation, and exactly one field must be rejected as invalid parameters.
Ensure supplied title or body values are never silently ignored.

}

#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub(crate) struct MemoryRequest {
#[schemars(description = "Action: compact|context|curate|handoff|recall|relate|remember")]
Expand Down
21 changes: 21 additions & 0 deletions src/rule_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ pub fn all() -> Vec<&'static str> {
/// config registration needed). Shells out to the same `agentflare hook
/// pre-tool-use` classifier Claude Code's PreToolUse hook already uses, so
/// there's one branch-guard decision, not a JS reimplementation of it.
/// PostToolUseFailure judge prompt: decides whether a tool failure is
/// genuine friction worth a vent call. Explicitly separates "a guard blocked
/// exactly what it exists to block, cleanly" (not friction) from "a guard
/// blocked me in a confusing or undocumented way" (friction).
pub const VENT_JUDGE_PROMPT: &str = r#"A tool call just failed. Tool call details:
$ARGUMENTS

Judge whether this is genuine FRICTION worth logging via agentflare's vent tool (mcp__flare__vent), using the SAME broad definition its own classifier uses -- not just "wrong/missing tool":
- severity is high / this genuinely blocked progress, OR
- the situation involves a pattern like broke/broken/fails/failing/wrong/should/missing/can't/cannot/error/panic/crash/hang/stuck/fabricated, OR
- this is a repeat of a problem that already happened earlier this session.

This covers a wrong or missing tool, a fabricated assumption about the environment, an environment/tooling gap, a confusing or misleading error message, or a tool silently doing the wrong thing -- not just outright blocking failures.

NOT friction: an ordinary expected failure (a grep with no matches, a file legitimately absent during exploration, a normal retry, slow builds, a routine denied permission), OR a guard/hook that blocked exactly the thing it exists to block, with a clear reason and a discoverable next step (e.g. a branch-protection guard refusing a write to main, or a destructive-command guard refusing rm -rf) -- that guard did its job; it is not a bug.

Friction IS still the right call when a guard's block is confusing or its workaround is undocumented -- e.g. it names an alternative that doesn't exist, or leaves you guessing which action it actually wants.

If this is genuine friction: respond NOT ok, with a reason that nudges calling mcp__flare__vent with a concise, specific message (state the actual wrong tool/assumption/gap, not a vague complaint) and an appropriate severity.
If this is an ordinary failure or a guard working as intended: respond ok."#;

pub const OPENCODE_BRANCH_GUARD_JS: &str = r#"// opencode has no PreToolUse hook wired to agentflare's own PreToolUse guard
// (that's Claude-Code-only, ~/.claude/settings.json), and opencode's own
// permission config isn't branch-aware -- so write/edit/patch tools went
Expand Down
Loading
Loading