Skip to content
Open
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
42 changes: 7 additions & 35 deletions crates/puffer-core/runtime/claude_tools/workflow/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ use super::store::{
find_team_for_session, git_ahead_count, git_dirty, git_head_commit, git_toplevel, is_git_repo,
load_store, messages_path, next_task_id, now_ms, register_team_member,
remove_claude_team_artifacts, resolve_recipients, save_store, shutdown_requests_path,
task_output_path, tasks_path, team_lead_agent_id, teams_path, todos_path,
validate_ask_user_questions, workflow_root, worktrees_path, write_claude_team_file, AgentInput,
AgentStore, AskUserQuestionInput, ClaudeTeamFile, ClaudeTeamMember, ConfigInput,
EnterWorktreeInput, ExitWorktreeInput, MessageStore, PendingShutdownRequest, PowerShellInput,
SendMessageInput, ShutdownRequestStore, StoredAgent, StoredMessage, StoredTask, StoredTeam,
StoredTodo, StoredWorktree, TaskStore, TeamCreateInput, TeamStore, TodoStore, TodoWriteInput,
WorktreeStore,
task_output_path, tasks_path, team_lead_agent_id, teams_path, validate_ask_user_questions,
workflow_root, worktrees_path, write_claude_team_file, AgentInput, AgentStore,
AskUserQuestionInput, ClaudeTeamFile, ClaudeTeamMember, ConfigInput, EnterWorktreeInput,
ExitWorktreeInput, MessageStore, PendingShutdownRequest, PowerShellInput, SendMessageInput,
ShutdownRequestStore, StoredAgent, StoredMessage, StoredTask, StoredTeam, StoredWorktree,
TaskStore, TeamCreateInput, TeamStore, WorktreeStore,
};
use super::task_runtime::{terminal_task_status, validate_todos, wait_for_child_output};
use super::task_runtime::{terminal_task_status, wait_for_child_output};
use crate::config_settings::{
config_setting_path, config_setting_scope, get_config_value, persist_config_setting,
scope_label, set_config_value,
Expand Down Expand Up @@ -618,33 +617,6 @@ pub(super) fn execute_team_delete(
}))?)
}

/// Executes the live `TodoWrite` workflow tool.
pub(super) fn execute_todo_write(
state: &mut AppState,
_cwd: &Path,
input: Value,
) -> Result<String> {
let parsed: TodoWriteInput =
serde_json::from_value(input).context("invalid TodoWrite input")?;
validate_todos(&parsed.todos)?;
let mut store = load_store::<TodoStore>(&todos_path(state.session.cwd.as_path()))?;
let old = store.todos.clone();
store.todos = parsed
.todos
.into_iter()
.map(|todo| StoredTodo {
content: todo.content,
status: todo.status,
active_form: todo.active_form,
})
.collect();
save_store(&todos_path(state.session.cwd.as_path()), &store)?;
Ok(serde_json::to_string_pretty(&json!({
"oldTodos": old,
"newTodos": store.todos
}))?)
}

/// Persists one background Bash task into the shared workflow task store.
pub(super) fn register_background_shell_task(
cwd: &Path,
Expand Down
168 changes: 165 additions & 3 deletions crates/puffer-core/runtime/claude_tools/workflow/task_runtime.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use super::store::{
load_store, now_ms, process_is_running, save_store, tasks_path, StoredTask, TaskStore,
TodoInputItem,
load_store, now_ms, process_is_running, save_store, tasks_path, StoredTask, StoredTodo,
TaskStore, TodoInputItem,
};
use crate::{AppState, MessageRole};
use anyhow::{Context, Result};
use puffer_config::ConfigPaths;
use serde_json::Value;
Expand All @@ -12,6 +13,8 @@ use std::thread;
use std::time::{Duration, Instant};

const MAX_PROCESS_OUTPUT_CHARS: usize = 30_000;
pub(super) const VERIFICATION_NUDGE: &str =
"NOTE: You just closed out 3+ tasks and none of them was a verification step. Before writing your final summary, spawn the verification agent (subagent_type=\"verification\"). You cannot self-assign PARTIAL by listing caveats in your summary — only the verifier issues a verdict.";

/// Returns the persisted runtime-agent output path for a task id.
pub(super) fn runtime_agent_output_path(session_cwd: &Path, task_id: &str) -> std::path::PathBuf {
Expand Down Expand Up @@ -84,6 +87,43 @@ pub(super) fn validate_todos(todos: &[TodoInputItem]) -> anyhow::Result<()> {
Ok(())
}

/// Returns true when the completed todo list should surface the verification reminder.
pub(super) fn should_emit_verification_nudge_for_todos(todos: &[StoredTodo]) -> bool {
todos.len() >= 3
&& todos.iter().all(|todo| todo.status == "completed")
&& !todos
.iter()
.any(|todo| contains_verification_marker(&todo.content))
}

/// Returns true when the completed visible task list should surface the verification reminder.
pub(super) fn should_emit_verification_nudge_for_tasks(tasks: &[StoredTask]) -> bool {
let visible = tasks
.iter()
.filter(|task| {
!task
.metadata
.get("_internal")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.collect::<Vec<_>>();
visible.len() >= 3
&& visible.iter().all(|task| task.status == "completed")
&& !visible
.iter()
.any(|task| contains_verification_marker(&task.subject))
}

/// Returns true when the current runtime context belongs to a nested subagent.
pub(super) fn is_subagent_context(state: &AppState) -> bool {
state.transcript.first().is_some_and(|message| {
message.role == MessageRole::System
&& (message.text.contains("You are a coding subagent")
|| message.text.contains("You are a verification specialist"))
})
}

/// Captures child-process output together with timeout state.
pub(super) struct TimedProcessOutput {
pub(super) stdout: String,
Expand Down Expand Up @@ -211,9 +251,18 @@ fn truncate_process_output(output: String) -> String {
output.chars().take(MAX_PROCESS_OUTPUT_CHARS).collect()
}

fn contains_verification_marker(text: &str) -> bool {
text.to_ascii_lowercase().contains("verif")
}

#[cfg(test)]
mod tests {
use super::{truncate_process_output, MAX_PROCESS_OUTPUT_CHARS};
use super::{
should_emit_verification_nudge_for_tasks, should_emit_verification_nudge_for_todos,
truncate_process_output, MAX_PROCESS_OUTPUT_CHARS,
};
use crate::runtime::claude_tools::workflow::store::{StoredTask, StoredTodo};
use serde_json::{Map, Value};

#[test]
fn truncate_process_output_leaves_short_text_unchanged() {
Expand All @@ -230,4 +279,117 @@ mod tests {
assert_eq!(truncated.chars().count(), MAX_PROCESS_OUTPUT_CHARS);
assert!(truncated.chars().all(|ch| ch == 'x'));
}

#[test]
fn verification_nudge_for_todos_requires_large_completed_non_verification_list() {
let todos = vec![
StoredTodo {
content: "Ship feature".to_string(),
status: "completed".to_string(),
active_form: "Shipping feature".to_string(),
},
StoredTodo {
content: "Run tests".to_string(),
status: "completed".to_string(),
active_form: "Running tests".to_string(),
},
StoredTodo {
content: "Write summary".to_string(),
status: "completed".to_string(),
active_form: "Writing summary".to_string(),
},
];
assert!(should_emit_verification_nudge_for_todos(&todos));

let mut with_verification = todos.clone();
with_verification[2].content = "Verification sweep".to_string();
assert!(!should_emit_verification_nudge_for_todos(
&with_verification
));
}

#[test]
fn verification_nudge_for_tasks_ignores_internal_entries() {
let mut internal_metadata = Map::new();
internal_metadata.insert("_internal".to_string(), Value::Bool(true));
let tasks = vec![
StoredTask {
task_id: "1".to_string(),
subject: "Ship feature".to_string(),
description: String::new(),
active_form: String::new(),
status: "completed".to_string(),
owner: None,
blocks: Vec::new(),
blocked_by: Vec::new(),
metadata: Map::new(),
output: None,
task_type: None,
command: None,
process_id: None,
output_file: None,
started_at_ms: None,
updated_at_ms: None,
exit_code: None,
},
StoredTask {
task_id: "2".to_string(),
subject: "Run tests".to_string(),
description: String::new(),
active_form: String::new(),
status: "completed".to_string(),
owner: None,
blocks: Vec::new(),
blocked_by: Vec::new(),
metadata: Map::new(),
output: None,
task_type: None,
command: None,
process_id: None,
output_file: None,
started_at_ms: None,
updated_at_ms: None,
exit_code: None,
},
StoredTask {
task_id: "3".to_string(),
subject: "Write summary".to_string(),
description: String::new(),
active_form: String::new(),
status: "completed".to_string(),
owner: None,
blocks: Vec::new(),
blocked_by: Vec::new(),
metadata: Map::new(),
output: None,
task_type: None,
command: None,
process_id: None,
output_file: None,
started_at_ms: None,
updated_at_ms: None,
exit_code: None,
},
StoredTask {
task_id: "4".to_string(),
subject: "Internal bookkeeping".to_string(),
description: String::new(),
active_form: String::new(),
status: "pending".to_string(),
owner: None,
blocks: Vec::new(),
blocked_by: Vec::new(),
metadata: internal_metadata,
output: None,
task_type: None,
command: None,
process_id: None,
output_file: None,
started_at_ms: None,
updated_at_ms: None,
exit_code: None,
},
];
assert!(should_emit_verification_nudge_for_tasks(&tasks));
}
}
16 changes: 13 additions & 3 deletions crates/puffer-core/runtime/claude_tools/workflow/task_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ use super::store::{
TaskStopInput, TaskStore, TaskUpdateInput,
};
use super::task_runtime::{
read_runtime_agent_output, read_task_output, refresh_stored_task, runtime_agent_output_path,
runtime_agent_terminal_status, terminal_task_status, wait_for_runtime_agent_output,
wait_for_stored_task,
is_subagent_context, read_runtime_agent_output, read_task_output, refresh_stored_task,
runtime_agent_output_path, runtime_agent_terminal_status,
should_emit_verification_nudge_for_tasks, terminal_task_status, wait_for_runtime_agent_output,
wait_for_stored_task, VERIFICATION_NUDGE,
};
use crate::AppState;
use anyhow::{anyhow, bail, Context, Result};
Expand Down Expand Up @@ -252,12 +253,21 @@ pub(super) fn execute_task_update(
}
}
task.updated_at_ms = Some(now_ms());
let verification_nudge_needed = !is_subagent_context(state)
&& status_change
.as_ref()
.and_then(|change| change.get("to"))
.and_then(Value::as_str)
== Some("completed")
&& should_emit_verification_nudge_for_tasks(&store.tasks);
save_store(&tasks_path(state.session.cwd.as_path()), &store)?;
Ok(serde_json::to_string_pretty(&json!({
"success": true,
"taskId": task_id,
"updatedFields": updated_fields,
"statusChange": status_change,
"verificationNudgeNeeded": verification_nudge_needed,
"note": verification_nudge_needed.then_some(VERIFICATION_NUDGE),
}))?)
}

Expand Down
34 changes: 31 additions & 3 deletions crates/puffer-core/runtime/claude_tools/workflow/todo_write.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
use crate::AppState;
use anyhow::Result;
use serde_json::Value;
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::Path;

use super::store::{load_store, save_store, todos_path, StoredTodo, TodoStore, TodoWriteInput};
use super::task_runtime::{
is_subagent_context, should_emit_verification_nudge_for_todos, validate_todos,
VERIFICATION_NUDGE,
};

/// Executes the Claude-compatible `TodoWrite` tool scaffold.
pub fn execute_todo_write(state: &mut AppState, cwd: &Path, input: Value) -> Result<String> {
super::support::execute_todo_write(state, cwd, input)
let parsed: TodoWriteInput =
serde_json::from_value(input).context("invalid TodoWrite input")?;
validate_todos(&parsed.todos)?;
let mut store = load_store::<TodoStore>(&todos_path(cwd))?;
let old = store.todos.clone();
store.todos = parsed
.todos
.into_iter()
.map(|todo| StoredTodo {
content: todo.content,
status: todo.status,
active_form: todo.active_form,
})
.collect();
let verification_nudge_needed =
!is_subagent_context(state) && should_emit_verification_nudge_for_todos(&store.todos);
save_store(&todos_path(cwd), &store)?;
Ok(serde_json::to_string_pretty(&json!({
"oldTodos": old,
"newTodos": store.todos,
"verificationNudgeNeeded": verification_nudge_needed,
"note": verification_nudge_needed.then_some(VERIFICATION_NUDGE),
}))?)
}
Loading