use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use crate::models::DomainError;
pub const TASK_FRAME_SCHEMA_V1: &str = "ee.task_frame.v1";
pub const TASK_FRAME_STORE_SCHEMA_V1: &str = "ee.task_frame.store.v1";
pub const TASK_FRAME_REPORT_SCHEMA_V1: &str = "ee.task_frame.report.v1";
pub const TASK_FRAME_ID_PREFIX: &str = "tf_";
pub const TASK_SUBGOAL_ID_PREFIX: &str = "tg_";
pub const NON_EXECUTING_CONTRACT: &str = "records task state only; never executes shell commands, plans tools, or mutates workspace files";
const TASK_FRAME_STORE_MAX_BYTES: u64 = 4 * 1024 * 1024;
static TASK_FRAME_STORE_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskFrameStatus {
Draft,
#[default]
Open,
Active,
Blocked,
Completed,
Abandoned,
}
impl TaskFrameStatus {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Open => "open",
Self::Active => "active",
Self::Blocked => "blocked",
Self::Completed => "completed",
Self::Abandoned => "abandoned",
}
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Abandoned)
}
#[must_use]
pub const fn is_active_candidate(self) -> bool {
matches!(self, Self::Open | Self::Active | Self::Blocked)
}
#[must_use]
pub fn can_transition_to(self, next: Self) -> bool {
if self == next {
return true;
}
match self {
Self::Draft => matches!(next, Self::Open | Self::Abandoned),
Self::Open => matches!(
next,
Self::Active | Self::Blocked | Self::Completed | Self::Abandoned
),
Self::Active => matches!(
next,
Self::Open | Self::Blocked | Self::Completed | Self::Abandoned
),
Self::Blocked => matches!(next, Self::Open | Self::Active | Self::Abandoned),
Self::Completed | Self::Abandoned => false,
}
}
}
impl FromStr for TaskFrameStatus {
type Err = DomainError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw.trim().to_ascii_lowercase().as_str() {
"draft" => Ok(Self::Draft),
"open" => Ok(Self::Open),
"active" => Ok(Self::Active),
"blocked" => Ok(Self::Blocked),
"completed" => Ok(Self::Completed),
"abandoned" => Ok(Self::Abandoned),
_ => Err(DomainError::Usage {
message: format!("Unknown task-frame status `{raw}`."),
repair: Some("Use draft|open|active|blocked|completed|abandoned.".to_owned()),
}),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskEvidenceLink {
pub kind: String,
pub id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskSubgoal {
pub id: String,
pub parent_id: Option<String>,
pub title: String,
pub status: TaskFrameStatus,
pub blockers: Vec<String>,
pub created_at: String,
pub updated_at: String,
pub closed_at: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskFrameRecord {
pub schema: String,
pub id: String,
pub workspace_root: String,
pub root_goal: String,
pub status: TaskFrameStatus,
pub actor: String,
pub source: String,
pub current_focus: Option<String>,
pub blockers: Vec<String>,
pub subgoals: Vec<TaskSubgoal>,
pub evidence_links: Vec<TaskEvidenceLink>,
pub suggested_commands: Vec<String>,
pub redaction_status: String,
pub non_executing_contract: String,
pub created_at: String,
pub updated_at: String,
pub closed_at: Option<String>,
pub close_reason: Option<String>,
}
impl TaskFrameRecord {
#[must_use]
pub fn active_subgoal_count(&self) -> usize {
self.subgoals
.iter()
.filter(|subgoal| subgoal.status.is_active_candidate())
.count()
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskFrameStoreDocument {
pub schema: String,
pub frames: Vec<TaskFrameRecord>,
}
impl Default for TaskFrameStoreDocument {
fn default() -> Self {
Self {
schema: TASK_FRAME_STORE_SCHEMA_V1.to_owned(),
frames: Vec::new(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskFrameReport {
pub schema: String,
pub command: String,
pub dry_run: bool,
pub mutated: bool,
pub store_path: String,
pub frame: Option<TaskFrameRecord>,
pub frames: Vec<TaskFrameRecord>,
pub selected_subgoal: Option<TaskSubgoal>,
pub operation: String,
pub non_executing_contract: String,
}
impl TaskFrameReport {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap_or_else(|error| {
serde_json::json!({
"schema": TASK_FRAME_REPORT_SCHEMA_V1,
"serializationError": error.to_string(),
})
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TaskFrameCreateOptions {
pub workspace_path: PathBuf,
pub goal: String,
pub actor: String,
pub status: TaskFrameStatus,
pub current_focus: Option<String>,
pub blockers: Vec<String>,
pub evidence_links: Vec<TaskEvidenceLink>,
pub created_at: Option<String>,
pub dry_run: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TaskFrameShowOptions {
pub workspace_path: PathBuf,
pub frame_id: Option<String>,
pub active: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TaskFrameUpdateOptions {
pub workspace_path: PathBuf,
pub frame_id: String,
pub status: Option<TaskFrameStatus>,
pub current_focus: Option<String>,
pub blockers: Vec<String>,
pub evidence_links: Vec<TaskEvidenceLink>,
pub updated_at: Option<String>,
pub dry_run: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TaskFrameCloseOptions {
pub workspace_path: PathBuf,
pub frame_id: String,
pub status: TaskFrameStatus,
pub reason: String,
pub closed_at: Option<String>,
pub dry_run: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TaskSubgoalAddOptions {
pub workspace_path: PathBuf,
pub frame_id: String,
pub parent_id: Option<String>,
pub title: String,
pub status: TaskFrameStatus,
pub blockers: Vec<String>,
pub created_at: Option<String>,
pub dry_run: bool,
}
#[must_use]
pub fn task_frame_store_path(workspace_path: &Path) -> PathBuf {
workspace_path.join(".ee").join("task_frames.json")
}
pub fn create_task_frame(options: &TaskFrameCreateOptions) -> Result<TaskFrameReport, DomainError> {
let workspace_root = workspace_root_string(&options.workspace_path);
ensure_non_empty("goal", &options.goal)?;
ensure_non_empty("actor", &options.actor)?;
if options.status.is_terminal() {
return Err(DomainError::Usage {
message: "New task frames must start in draft, open, active, or blocked state."
.to_owned(),
repair: Some("Use --status open, then close the frame explicitly.".to_owned()),
});
}
let store_path = task_frame_store_path(&options.workspace_path);
let mut store = read_store(&store_path)?;
let now = options.created_at.clone().unwrap_or_else(now_rfc3339);
let (root_goal, goal_redacted) = redact_task_text(options.goal.trim());
let actor = options.actor.trim().to_owned();
let frame_id = stable_id(
TASK_FRAME_ID_PREFIX,
&[&workspace_root, &root_goal, &actor, &now],
);
if store.frames.iter().any(|frame| frame.id == frame_id) {
return Err(DomainError::Usage {
message: format!("Task frame already exists: {frame_id}"),
repair: Some(format!("ee task-frame show {frame_id} --json")),
});
}
let (current_focus, focus_redacted) = redact_optional_task_text(options.current_focus.clone());
let (blockers, blockers_redacted) = redact_task_strings(&options.blockers);
let redacted = goal_redacted || focus_redacted || blockers_redacted;
let frame = TaskFrameRecord {
schema: TASK_FRAME_SCHEMA_V1.to_owned(),
id: frame_id,
workspace_root,
root_goal,
status: options.status,
actor,
source: "ee task-frame create".to_owned(),
current_focus,
blockers,
subgoals: Vec::new(),
evidence_links: normalized_evidence_links(&options.evidence_links),
suggested_commands: suggested_commands(None),
redaction_status: redaction_status(redacted),
non_executing_contract: NON_EXECUTING_CONTRACT.to_owned(),
created_at: now.clone(),
updated_at: now,
closed_at: None,
close_reason: None,
};
if !options.dry_run {
store.frames.push(frame.clone());
sort_frames(&mut store.frames);
write_store(&store_path, &store)?;
}
Ok(report(
"task-frame create",
options.dry_run,
!options.dry_run,
&store_path,
"create",
Some(frame),
Vec::new(),
None,
))
}
pub fn show_task_frame(options: &TaskFrameShowOptions) -> Result<TaskFrameReport, DomainError> {
let store_path = task_frame_store_path(&options.workspace_path);
let store = read_store(&store_path)?;
let frame = match (&options.frame_id, options.active) {
(Some(frame_id), _) => find_frame(&store.frames, frame_id)?.clone(),
(None, true) => select_active_frame(&store.frames)?.clone(),
(None, false) => {
return Ok(report(
"task-frame show",
false,
false,
&store_path,
"list",
None,
store.frames,
None,
));
}
};
Ok(report(
"task-frame show",
false,
false,
&store_path,
"show",
Some(frame),
Vec::new(),
None,
))
}
pub fn update_task_frame(options: &TaskFrameUpdateOptions) -> Result<TaskFrameReport, DomainError> {
let store_path = task_frame_store_path(&options.workspace_path);
let mut store = read_store(&store_path)?;
let index = find_frame_index(&store.frames, &options.frame_id)?;
let mut frame = store.frames[index].clone();
let now = options.updated_at.clone().unwrap_or_else(now_rfc3339);
if let Some(next_status) = options.status {
validate_transition(frame.status, next_status, "task frame")?;
frame.status = next_status;
if next_status.is_terminal() {
frame.closed_at = Some(now.clone());
}
}
let mut redacted = frame.redaction_status == "redacted";
if options.current_focus.is_some() {
let (current_focus, focus_redacted) =
redact_optional_task_text(options.current_focus.clone());
frame.current_focus = current_focus;
redacted |= focus_redacted;
}
redacted |= append_unique_task_strings(&mut frame.blockers, &options.blockers);
append_unique_evidence_links(&mut frame.evidence_links, &options.evidence_links);
frame.redaction_status = redaction_status(redacted);
frame.updated_at = now;
frame.suggested_commands = suggested_commands(Some(&frame.id));
if !options.dry_run {
store.frames[index] = frame.clone();
sort_frames(&mut store.frames);
write_store(&store_path, &store)?;
}
Ok(report(
"task-frame update",
options.dry_run,
!options.dry_run,
&store_path,
"update",
Some(frame),
Vec::new(),
None,
))
}
pub fn close_task_frame(options: &TaskFrameCloseOptions) -> Result<TaskFrameReport, DomainError> {
if !matches!(
options.status,
TaskFrameStatus::Completed | TaskFrameStatus::Abandoned
) {
return Err(DomainError::Usage {
message: "Closing a task frame requires completed or abandoned status.".to_owned(),
repair: Some("Use --status completed or --status abandoned.".to_owned()),
});
}
ensure_non_empty("reason", &options.reason)?;
let store_path = task_frame_store_path(&options.workspace_path);
let mut store = read_store(&store_path)?;
let index = find_frame_index(&store.frames, &options.frame_id)?;
let mut frame = store.frames[index].clone();
validate_transition(frame.status, options.status, "task frame")?;
let now = options.closed_at.clone().unwrap_or_else(now_rfc3339);
frame.status = options.status;
frame.updated_at = now.clone();
frame.closed_at = Some(now);
let (close_reason, reason_redacted) = redact_task_text(options.reason.trim());
frame.close_reason = Some(close_reason);
frame.redaction_status =
redaction_status(frame.redaction_status == "redacted" || reason_redacted);
frame.suggested_commands = suggested_commands(Some(&frame.id));
if !options.dry_run {
store.frames[index] = frame.clone();
sort_frames(&mut store.frames);
write_store(&store_path, &store)?;
}
Ok(report(
"task-frame close",
options.dry_run,
!options.dry_run,
&store_path,
"close",
Some(frame),
Vec::new(),
None,
))
}
pub fn add_task_subgoal(options: &TaskSubgoalAddOptions) -> Result<TaskFrameReport, DomainError> {
ensure_non_empty("title", &options.title)?;
if options.status.is_terminal() {
return Err(DomainError::Usage {
message: "New subgoals must start in draft, open, active, or blocked state.".to_owned(),
repair: Some("Create the subgoal first, then close it explicitly.".to_owned()),
});
}
let store_path = task_frame_store_path(&options.workspace_path);
let mut store = read_store(&store_path)?;
let index = find_frame_index(&store.frames, &options.frame_id)?;
let mut frame = store.frames[index].clone();
if frame.status.is_terminal() {
return Err(DomainError::Usage {
message: format!("Cannot add subgoals to terminal task frame `{}`.", frame.id),
repair: Some("Create a new task frame for follow-up work.".to_owned()),
});
}
let parent_id = normalize_optional(options.parent_id.clone());
if let Some(parent_id) = &parent_id {
if !frame
.subgoals
.iter()
.any(|subgoal| &subgoal.id == parent_id)
{
return Err(DomainError::NotFound {
resource: "task subgoal".to_owned(),
id: parent_id.clone(),
repair: Some(format!("ee task-frame show {} --json", frame.id)),
});
}
}
let now = options.created_at.clone().unwrap_or_else(now_rfc3339);
let (title, title_redacted) = redact_task_text(options.title.trim());
let (blockers, blockers_redacted) = redact_task_strings(&options.blockers);
let subgoal = TaskSubgoal {
id: stable_id(TASK_SUBGOAL_ID_PREFIX, &[&frame.id, &title, &now]),
parent_id,
title,
status: options.status,
blockers,
created_at: now.clone(),
updated_at: now.clone(),
closed_at: None,
};
frame.subgoals.push(subgoal.clone());
frame.subgoals.sort_by(|left, right| {
left.parent_id
.cmp(&right.parent_id)
.then(left.created_at.cmp(&right.created_at))
.then(left.id.cmp(&right.id))
});
frame.updated_at = now;
frame.suggested_commands = suggested_commands(Some(&frame.id));
frame.redaction_status = redaction_status(
frame.redaction_status == "redacted" || title_redacted || blockers_redacted,
);
if !options.dry_run {
store.frames[index] = frame.clone();
sort_frames(&mut store.frames);
write_store(&store_path, &store)?;
}
Ok(report(
"task-frame subgoal add",
options.dry_run,
!options.dry_run,
&store_path,
"subgoal_add",
Some(frame),
Vec::new(),
Some(subgoal),
))
}
fn read_store(store_path: &Path) -> Result<TaskFrameStoreDocument, DomainError> {
ensure_no_symlink_components(store_path, "read")?;
let metadata = match fs::symlink_metadata(store_path) {
Ok(metadata) => metadata,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(TaskFrameStoreDocument::default());
}
Err(error) => {
return Err(DomainError::Storage {
message: format!(
"Failed to inspect task-frame store `{}`: {error}",
store_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
});
}
};
if !metadata.file_type().is_file() {
return Err(DomainError::Storage {
message: format!(
"Refusing to read task-frame store `{}` because it is not a regular file.",
store_path.display()
),
repair: Some("Replace .ee/task_frames.json with a regular JSON file.".to_owned()),
});
}
if metadata.len() > TASK_FRAME_STORE_MAX_BYTES {
return Err(DomainError::Storage {
message: format!(
"Refusing to read task-frame store `{}`: file is {} bytes, exceeding the {TASK_FRAME_STORE_MAX_BYTES}-byte ceiling.",
store_path.display(),
metadata.len()
),
repair: Some(format!(
"Trim or remove `{}` so it is under {TASK_FRAME_STORE_MAX_BYTES} bytes.",
store_path.display()
)),
});
}
let text = read_store_file(store_path).map_err(|error| DomainError::Storage {
message: format!(
"Failed to read task-frame store `{}`: {error}",
store_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
})?;
if (text.len() as u64) > TASK_FRAME_STORE_MAX_BYTES {
return Err(DomainError::Storage {
message: format!(
"Refusing to read task-frame store `{}`: file grew past the {TASK_FRAME_STORE_MAX_BYTES}-byte cap after the metadata check (TOCTOU).",
store_path.display()
),
repair: Some(format!(
"Trim or remove `{}` so it is under {TASK_FRAME_STORE_MAX_BYTES} bytes.",
store_path.display()
)),
});
}
serde_json::from_str(&text).map_err(|error| DomainError::Storage {
message: format!(
"Failed to parse task-frame store `{}`: {error}",
store_path.display()
),
repair: Some("Inspect .ee/task_frames.json for malformed JSON.".to_owned()),
})
}
fn read_store_file(store_path: &Path) -> std::io::Result<String> {
let mut file = open_store_file_for_read(store_path)?;
let mut text = String::new();
(&mut file)
.take(TASK_FRAME_STORE_MAX_BYTES.saturating_add(1))
.read_to_string(&mut text)?;
Ok(text)
}
fn open_store_file_for_read(store_path: &Path) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.read(true);
configure_task_frame_open_no_follow(&mut options);
options.open(store_path)
}
#[cfg(all(unix, not(any(target_os = "espidf", target_os = "horizon"))))]
fn configure_task_frame_open_no_follow(options: &mut OpenOptions) {
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32);
}
#[cfg(not(all(unix, not(any(target_os = "espidf", target_os = "horizon")))))]
fn configure_task_frame_open_no_follow(_options: &mut OpenOptions) {}
fn write_store(store_path: &Path, store: &TaskFrameStoreDocument) -> Result<(), DomainError> {
ensure_no_symlink_components(store_path, "write")?;
if let Some(parent) = store_path.parent() {
fs::create_dir_all(parent).map_err(|error| DomainError::Storage {
message: format!(
"Failed to create task-frame directory `{}`: {error}",
parent.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
})?;
}
ensure_no_symlink_components(store_path, "write")?;
ensure_write_store_final_path(store_path)?;
let text = serde_json::to_string_pretty(store).map_err(|error| DomainError::Storage {
message: format!("Failed to serialize task-frame store: {error}"),
repair: Some("Report this serialization bug.".to_owned()),
})? + "\n";
let temp_path = unique_store_temp_path(store_path)?;
ensure_no_symlink_components(&temp_path, "write")?;
ensure_write_store_temp_path(&temp_path)?;
write_store_temp_file(&temp_path, &text)?;
publish_store_temp_file(store_path, &temp_path)
}
fn unique_store_temp_path(store_path: &Path) -> Result<PathBuf, DomainError> {
let file_name = store_path.file_name().ok_or_else(|| DomainError::Storage {
message: format!(
"Failed to build task-frame temp store path for `{}`: missing file name.",
store_path.display()
),
repair: Some("Use a task-frame store path that names a JSON file.".to_owned()),
})?;
let counter = TASK_FRAME_STORE_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let mut temp_name = OsString::from(".");
temp_name.push(file_name);
temp_name.push(OsString::from(format!(
".{}.{}.{}.tmp",
std::process::id(),
now,
counter
)));
Ok(match store_path.parent() {
Some(parent) => parent.join(&temp_name),
None => PathBuf::from(temp_name),
})
}
fn publish_store_temp_file(store_path: &Path, temp_path: &Path) -> Result<(), DomainError> {
ensure_no_symlink_components(store_path, "write")?;
ensure_write_store_final_path(store_path)?;
ensure_no_symlink_components(temp_path, "write")?;
ensure_created_write_store_temp_path(temp_path)?;
fs::rename(temp_path, store_path).map_err(|error| DomainError::Storage {
message: format!(
"Failed to publish task-frame store `{}`: {error}",
store_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
})
}
fn ensure_write_store_final_path(store_path: &Path) -> Result<(), DomainError> {
match fs::symlink_metadata(store_path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(DomainError::Storage {
message: format!(
"Refusing to write task-frame store `{}` because it is not a regular file.",
store_path.display()
),
repair: Some("Replace .ee/task_frames.json with a regular JSON file.".to_owned()),
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(DomainError::Storage {
message: format!(
"Failed to inspect task-frame store `{}` before write: {error}",
store_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
}),
}
}
fn ensure_write_store_temp_path(temp_path: &Path) -> Result<(), DomainError> {
match fs::symlink_metadata(temp_path) {
Ok(metadata) if metadata.file_type().is_file() => Err(DomainError::Storage {
message: format!(
"Refusing to write task-frame temp store `{}` because it already exists.",
temp_path.display()
),
repair: Some(
"Retry the task-frame command so a fresh temp file name is chosen.".to_owned(),
),
}),
Ok(_) => Err(DomainError::Storage {
message: format!(
"Refusing to write task-frame temp store `{}` because it is not a regular file.",
temp_path.display()
),
repair: Some("Inspect the task-frame temp path before retrying.".to_owned()),
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(DomainError::Storage {
message: format!(
"Failed to inspect task-frame temp store `{}` before write: {error}",
temp_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
}),
}
}
fn ensure_created_write_store_temp_path(temp_path: &Path) -> Result<(), DomainError> {
match fs::symlink_metadata(temp_path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(()),
Ok(_) => Err(DomainError::Storage {
message: format!(
"Refusing to publish task-frame temp store `{}` because it is not a regular file.",
temp_path.display()
),
repair: Some("Retry the task-frame command so the temp file is recreated.".to_owned()),
}),
Err(error) => Err(DomainError::Storage {
message: format!(
"Failed to inspect task-frame temp store `{}` before publish: {error}",
temp_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
}),
}
}
fn write_store_temp_file(temp_path: &Path, text: &str) -> Result<(), DomainError> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(temp_path)
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to create task-frame temp store `{}`: {error}",
temp_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
})?;
file.write_all(text.as_bytes())
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to write task-frame temp store `{}`: {error}",
temp_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
})?;
file.sync_all().map_err(|error| DomainError::Storage {
message: format!(
"Failed to sync task-frame temp store `{}`: {error}",
temp_path.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
})
}
fn ensure_no_symlink_components(path: &Path, operation: &'static str) -> Result<(), DomainError> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(DomainError::Storage {
message: format!(
"Refusing to {operation} task-frame store `{}` through symlinked path component `{}`.",
path.display(),
current.display()
),
repair: Some(
"Replace the symlink with a real workspace .ee path before retrying."
.to_owned(),
),
});
}
Ok(_) => {}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(());
}
Err(error) => {
return Err(DomainError::Storage {
message: format!(
"Failed to inspect task-frame store path component `{}` before {operation}: {error}",
current.display()
),
repair: Some("Check workspace .ee permissions.".to_owned()),
});
}
}
}
Ok(())
}
#[expect(clippy::too_many_arguments)]
fn report(
command: &str,
dry_run: bool,
mutated: bool,
store_path: &Path,
operation: &str,
frame: Option<TaskFrameRecord>,
frames: Vec<TaskFrameRecord>,
selected_subgoal: Option<TaskSubgoal>,
) -> TaskFrameReport {
TaskFrameReport {
schema: TASK_FRAME_REPORT_SCHEMA_V1.to_owned(),
command: command.to_owned(),
dry_run,
mutated,
store_path: store_path.display().to_string(),
frame,
frames,
selected_subgoal,
operation: operation.to_owned(),
non_executing_contract: NON_EXECUTING_CONTRACT.to_owned(),
}
}
fn find_frame<'a>(
frames: &'a [TaskFrameRecord],
frame_id: &str,
) -> Result<&'a TaskFrameRecord, DomainError> {
frames
.iter()
.find(|frame| frame.id == frame_id)
.ok_or_else(|| DomainError::NotFound {
resource: "task frame".to_owned(),
id: frame_id.to_owned(),
repair: Some("ee task-frame show --active --json".to_owned()),
})
}
fn find_frame_index(frames: &[TaskFrameRecord], frame_id: &str) -> Result<usize, DomainError> {
frames
.iter()
.position(|frame| frame.id == frame_id)
.ok_or_else(|| DomainError::NotFound {
resource: "task frame".to_owned(),
id: frame_id.to_owned(),
repair: Some("ee task-frame show --json".to_owned()),
})
}
fn select_active_frame(frames: &[TaskFrameRecord]) -> Result<&TaskFrameRecord, DomainError> {
let mut active = frames
.iter()
.filter(|frame| frame.status.is_active_candidate());
let first = active.next().ok_or_else(|| DomainError::NotFound {
resource: "active task frame".to_owned(),
id: "active".to_owned(),
repair: Some("ee task-frame create --goal \"...\" --json".to_owned()),
})?;
if active.next().is_some() {
return Err(DomainError::Usage {
message: "Multiple active task frames exist in this workspace.".to_owned(),
repair: Some("Pass an explicit FRAME_ID to ee task-frame show.".to_owned()),
});
}
Ok(first)
}
fn validate_transition(
current: TaskFrameStatus,
next: TaskFrameStatus,
label: &str,
) -> Result<(), DomainError> {
if current.can_transition_to(next) {
Ok(())
} else {
Err(DomainError::PolicyDenied {
message: format!(
"Invalid {label} status transition: {} -> {}.",
current.as_str(),
next.as_str()
),
repair: Some("Terminal task-frame states cannot be reopened in place.".to_owned()),
})
}
}
fn workspace_root_string(workspace_path: &Path) -> String {
workspace_path.display().to_string()
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn stable_id(prefix: &str, parts: &[&str]) -> String {
let mut hasher = blake3::Hasher::new();
for part in parts {
hasher.update(part.as_bytes());
hasher.update(b"\0");
}
let digest = hasher.finalize().to_hex().to_string();
format!("{prefix}{}", &digest[..26])
}
fn ensure_non_empty(field: &str, value: &str) -> Result<(), DomainError> {
if value.trim().is_empty() {
Err(DomainError::Usage {
message: format!("Task-frame {field} must not be empty."),
repair: Some(format!("Pass a non-empty --{field} value.")),
})
} else {
Ok(())
}
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.and_then(|inner| {
let trimmed = inner.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
}
fn normalized_strings(values: &[String]) -> Vec<String> {
let mut normalized = values
.iter()
.filter_map(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
.collect::<Vec<_>>();
normalized.sort();
normalized.dedup();
normalized
}
#[expect(
dead_code,
reason = "utility prepared for future subgoal bulk operations"
)]
fn append_unique_strings(target: &mut Vec<String>, values: &[String]) {
target.extend(normalized_strings(values));
target.sort();
target.dedup();
}
fn redact_task_text(value: &str) -> (String, bool) {
let report = crate::policy::redact_secret_like_content(value.trim());
(report.content, report.redacted)
}
fn redact_optional_task_text(value: Option<String>) -> (Option<String>, bool) {
match normalize_optional(value) {
Some(inner) => {
let (redacted, changed) = redact_task_text(&inner);
(Some(redacted), changed)
}
None => (None, false),
}
}
fn redact_task_strings(values: &[String]) -> (Vec<String>, bool) {
let mut changed = false;
let mut redacted = values
.iter()
.filter_map(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
let (redacted, item_changed) = redact_task_text(trimmed);
changed |= item_changed;
Some(redacted)
}
})
.collect::<Vec<_>>();
redacted.sort();
redacted.dedup();
(redacted, changed)
}
fn append_unique_task_strings(target: &mut Vec<String>, values: &[String]) -> bool {
let (redacted, changed) = redact_task_strings(values);
target.extend(redacted);
target.sort();
target.dedup();
changed
}
fn redaction_status(redacted: bool) -> String {
if redacted {
"redacted".to_owned()
} else {
"none".to_owned()
}
}
fn normalized_evidence_links(values: &[TaskEvidenceLink]) -> Vec<TaskEvidenceLink> {
let mut normalized = values
.iter()
.filter_map(|link| {
let kind = link.kind.trim();
let id = link.id.trim();
if kind.is_empty() || id.is_empty() {
None
} else {
Some(TaskEvidenceLink {
kind: kind.to_owned(),
id: id.to_owned(),
})
}
})
.collect::<Vec<_>>();
normalized.sort_by(|left, right| left.kind.cmp(&right.kind).then(left.id.cmp(&right.id)));
normalized.dedup();
normalized
}
fn append_unique_evidence_links(target: &mut Vec<TaskEvidenceLink>, values: &[TaskEvidenceLink]) {
target.extend(normalized_evidence_links(values));
target.sort_by(|left, right| left.kind.cmp(&right.kind).then(left.id.cmp(&right.id)));
target.dedup();
}
fn sort_frames(frames: &mut [TaskFrameRecord]) {
frames.sort_by(|left, right| {
left.status
.as_str()
.cmp(right.status.as_str())
.then(left.created_at.cmp(&right.created_at))
.then(left.id.cmp(&right.id))
});
}
fn suggested_commands(frame_id: Option<&str>) -> Vec<String> {
match frame_id {
Some(id) => vec![
format!("ee task-frame show {id} --json"),
format!("ee focus suggest --task-frame {id} --json"),
],
None => vec![
"ee task-frame show --active --json".to_owned(),
"ee plan recipe list --json".to_owned(),
],
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), String>;
fn temp_workspace(name: &str) -> Result<PathBuf, String> {
let root = std::env::temp_dir().join(format!(
"ee-task-frame-{name}-{}",
Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
fs::create_dir_all(&root).map_err(|error| error.to_string())?;
Ok(root)
}
fn create_options(workspace_path: PathBuf) -> TaskFrameCreateOptions {
TaskFrameCreateOptions {
workspace_path,
goal: "Ship task frame support".to_owned(),
actor: "cod-pane6".to_owned(),
status: TaskFrameStatus::Open,
current_focus: Some("wire CLI".to_owned()),
blockers: vec!["mail unavailable".to_owned()],
evidence_links: vec![TaskEvidenceLink {
kind: "bead".to_owned(),
id: "eidetic_engine_cli-swx.3".to_owned(),
}],
created_at: Some("2026-05-04T00:00:00Z".to_owned()),
dry_run: false,
}
}
#[test]
fn task_frame_status_parse_normalizes_cli_values() -> TestResult {
assert_eq!(
TaskFrameStatus::from_str(" Active ").map_err(|error| error.message())?,
TaskFrameStatus::Active
);
assert_eq!(
TaskFrameStatus::from_str("COMPLETED").map_err(|error| error.message())?,
TaskFrameStatus::Completed
);
Ok(())
}
#[test]
fn create_frame_persists_non_executing_record() -> TestResult {
let workspace = temp_workspace("create")?;
let report =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame = report.frame.ok_or_else(|| "missing frame".to_owned())?;
assert_eq!(frame.schema, TASK_FRAME_SCHEMA_V1);
assert_eq!(frame.status, TaskFrameStatus::Open);
assert!(
frame
.non_executing_contract
.contains("never executes shell commands")
);
assert!(task_frame_store_path(&workspace).exists());
Ok(())
}
#[test]
fn dry_run_does_not_write_store() -> TestResult {
let workspace = temp_workspace("dry-run")?;
let mut options = create_options(workspace.clone());
options.dry_run = true;
let report = create_task_frame(&options).map_err(|e| e.message())?;
assert!(report.dry_run);
assert!(!report.mutated);
assert!(!task_frame_store_path(&workspace).exists());
Ok(())
}
#[test]
fn create_frame_id_uses_normalized_stored_text() -> TestResult {
let workspace = temp_workspace("normalized-frame-id")?;
let mut raw = create_options(workspace.clone());
raw.goal = " Rotate token=abc123 ".to_owned();
raw.actor = " cod-pane6 ".to_owned();
raw.current_focus = None;
raw.blockers = Vec::new();
raw.evidence_links = Vec::new();
raw.dry_run = true;
let mut normalized = raw.clone();
normalized.goal = format!(
"Rotate token={}",
crate::policy::redaction_placeholder("token")
);
normalized.actor = "cod-pane6".to_owned();
let raw_frame = create_task_frame(&raw)
.map_err(|e| e.message())?
.frame
.ok_or_else(|| "missing raw frame".to_owned())?;
let normalized_frame = create_task_frame(&normalized)
.map_err(|e| e.message())?
.frame
.ok_or_else(|| "missing normalized frame".to_owned())?;
assert_eq!(raw_frame.root_goal, normalized_frame.root_goal);
assert_eq!(raw_frame.actor, normalized_frame.actor);
assert_eq!(raw_frame.id, normalized_frame.id);
assert_eq!(raw_frame.redaction_status, "redacted");
assert!(!task_frame_store_path(&workspace).exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn create_frame_rejects_symlinked_ee_directory() -> TestResult {
use std::os::unix::fs::symlink;
let workspace = temp_workspace("symlink-ee")?;
let real_ee = workspace.join("real-ee");
fs::create_dir_all(&real_ee).map_err(|error| error.to_string())?;
symlink(&real_ee, workspace.join(".ee")).map_err(|error| error.to_string())?;
let error = match create_task_frame(&create_options(workspace.clone())) {
Ok(report) => return Err(format!("symlinked .ee should fail, got {report:?}")),
Err(error) => error,
};
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("symlink"),
"error should mention symlink"
);
assert!(
!real_ee.join("task_frames.json").exists(),
"task-frame store must not be written through symlinked .ee"
);
Ok(())
}
#[test]
fn create_frame_rejects_store_directory_before_write() -> TestResult {
let workspace = temp_workspace("write-store-directory")?;
fs::create_dir_all(task_frame_store_path(&workspace)).map_err(|error| error.to_string())?;
let error = match create_task_frame(&create_options(workspace.clone())) {
Ok(report) => return Err(format!("store directory should fail, got {report:?}")),
Err(error) => error,
};
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("not a regular file"),
"error should mention regular file"
);
assert!(
task_frame_store_path(&workspace).is_dir(),
"task-frame write must leave non-regular final path untouched"
);
Ok(())
}
#[test]
fn create_frame_ignores_stale_legacy_temp_store_without_truncating() -> TestResult {
let workspace = temp_workspace("write-existing-temp")?;
let store_path = task_frame_store_path(&workspace);
let temp_path = store_path.with_extension("json.tmp");
let parent = temp_path
.parent()
.ok_or_else(|| "missing temp parent".to_owned())?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
fs::write(&temp_path, b"keep me").map_err(|error| error.to_string())?;
let report = create_task_frame(&create_options(workspace.clone()))
.map_err(|error| error.message())?;
let frame = report.frame.as_ref().ok_or_else(|| {
"create task-frame report should include the created frame".to_owned()
})?;
assert!(
frame.id.starts_with(TASK_FRAME_ID_PREFIX),
"frame should be created despite stale legacy temp file"
);
assert_eq!(
fs::read_to_string(&temp_path).map_err(|error| error.to_string())?,
"keep me",
"task-frame write must not truncate an existing temp file"
);
assert!(
store_path.exists(),
"task-frame write should publish through a unique temp file"
);
let persisted = read_store(&store_path).map_err(|error| error.message())?;
assert_eq!(
persisted.frames.len(),
1,
"task-frame write should persist one frame"
);
assert_eq!(
persisted.frames[0].id, frame.id,
"persisted task frame should match the created report"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn publish_frame_store_rechecks_final_symlink_before_rename() -> TestResult {
use std::os::unix::fs::symlink;
let workspace = temp_workspace("publish-final-symlink")?;
let store_path = task_frame_store_path(&workspace);
let temp_path = store_path.with_extension("json.tmp");
let outside_store = workspace.join("outside-task-frames.json");
let parent = temp_path
.parent()
.ok_or_else(|| "missing temp parent".to_owned())?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
write_store_temp_file(
&temp_path,
&format!(
"{{\"schema\":\"{}\",\"frames\":[]}}\n",
TASK_FRAME_STORE_SCHEMA_V1
),
)
.map_err(|error| error.message())?;
fs::write(&outside_store, b"outside sentinel").map_err(|error| error.to_string())?;
symlink(&outside_store, &store_path).map_err(|error| error.to_string())?;
let error = publish_store_temp_file(&store_path, &temp_path)
.expect_err("final symlink should reject task-frame store publish");
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("symlinked path component")
|| error.message().contains("not a regular file"),
"unexpected final symlink error: {}",
error.message()
);
assert_eq!(
fs::read_to_string(&outside_store).map_err(|error| error.to_string())?,
"outside sentinel",
"outside task-frame store must remain unchanged"
);
assert!(
fs::symlink_metadata(&temp_path)
.map_err(|error| error.to_string())?
.file_type()
.is_file(),
"temp task-frame store should remain after publish rejection"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn publish_frame_store_rechecks_temp_symlink_before_rename() -> TestResult {
use std::os::unix::fs::symlink;
let workspace = temp_workspace("publish-temp-symlink")?;
let store_path = task_frame_store_path(&workspace);
let temp_path = store_path.with_extension("json.tmp");
let temp_backup = store_path.with_extension("json.tmp.backup");
let outside_store = workspace.join("outside-temp-task-frames.json");
let parent = temp_path
.parent()
.ok_or_else(|| "missing temp parent".to_owned())?;
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
write_store_temp_file(
&temp_path,
&format!(
"{{\"schema\":\"{}\",\"frames\":[]}}\n",
TASK_FRAME_STORE_SCHEMA_V1
),
)
.map_err(|error| error.message())?;
fs::rename(&temp_path, &temp_backup).map_err(|error| error.to_string())?;
fs::write(&outside_store, b"outside sentinel").map_err(|error| error.to_string())?;
symlink(&outside_store, &temp_path).map_err(|error| error.to_string())?;
let error = publish_store_temp_file(&store_path, &temp_path)
.expect_err("temp symlink should reject task-frame store publish");
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("symlinked path component")
|| error.message().contains("not a regular file"),
"unexpected temp symlink error: {}",
error.message()
);
assert_eq!(
fs::read_to_string(&outside_store).map_err(|error| error.to_string())?,
"outside sentinel",
"outside task-frame temp target must remain unchanged"
);
assert!(
fs::symlink_metadata(&temp_path)
.map_err(|error| error.to_string())?
.file_type()
.is_symlink(),
"swapped temp symlink should remain after publish rejection"
);
assert!(
!store_path.exists(),
"final task-frame store must not publish from symlinked temp path"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn show_frame_rejects_symlinked_store_file_before_read() -> TestResult {
use std::os::unix::fs::symlink;
let workspace = temp_workspace("symlink-store")?;
let ee_dir = workspace.join(".ee");
fs::create_dir_all(&ee_dir).map_err(|error| error.to_string())?;
let outside_store = workspace.join("outside-task-frames.json");
fs::write(
&outside_store,
format!(
"{{\"schema\":\"{}\",\"frames\":[]}}\n",
TASK_FRAME_STORE_SCHEMA_V1
),
)
.map_err(|error| error.to_string())?;
symlink(&outside_store, task_frame_store_path(&workspace))
.map_err(|error| error.to_string())?;
let error = match show_task_frame(&TaskFrameShowOptions {
workspace_path: workspace,
frame_id: None,
active: true,
}) {
Ok(report) => return Err(format!("symlinked store should fail, got {report:?}")),
Err(error) => error,
};
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("symlink"),
"error should mention symlink"
);
assert_eq!(
fs::read_to_string(&outside_store).map_err(|error| error.to_string())?,
format!(
"{{\"schema\":\"{}\",\"frames\":[]}}\n",
TASK_FRAME_STORE_SCHEMA_V1
)
);
Ok(())
}
#[cfg(unix)]
#[test]
fn task_frame_store_final_read_open_rejects_symlinked_store_file() -> TestResult {
use std::os::unix::fs::symlink;
let workspace = temp_workspace("final-read-symlink")?;
let ee_dir = workspace.join(".ee");
fs::create_dir_all(&ee_dir).map_err(|error| error.to_string())?;
let outside_store = workspace.join("outside-task-frames.json");
let outside_text = format!(
"{{\"schema\":\"{}\",\"frames\":[]}}\n",
TASK_FRAME_STORE_SCHEMA_V1
);
fs::write(&outside_store, outside_text.as_bytes()).map_err(|error| error.to_string())?;
let linked_store = task_frame_store_path(&workspace);
symlink(&outside_store, &linked_store).map_err(|error| error.to_string())?;
let error = open_store_file_for_read(&linked_store)
.expect_err("final task-frame store read open must reject symlinks");
assert_ne!(
error.kind(),
std::io::ErrorKind::NotFound,
"final symlink read should fail because the path is a symlink"
);
assert_eq!(
fs::read_to_string(&outside_store).map_err(|error| error.to_string())?,
outside_text,
"task-frame store read helper must not follow the symlink target"
);
Ok(())
}
#[test]
fn show_frame_rejects_store_directory_before_read() -> TestResult {
let workspace = temp_workspace("store-directory")?;
fs::create_dir_all(task_frame_store_path(&workspace)).map_err(|error| error.to_string())?;
let error = match show_task_frame(&TaskFrameShowOptions {
workspace_path: workspace,
frame_id: None,
active: true,
}) {
Ok(report) => return Err(format!("store directory should fail, got {report:?}")),
Err(error) => error,
};
assert_eq!(error.code(), "storage");
assert!(
error.message().contains("not a regular file"),
"error should mention regular file"
);
Ok(())
}
#[test]
fn nested_subgoals_preserve_parent_relation() -> TestResult {
let workspace = temp_workspace("subgoals")?;
let created =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame_id = created.frame.ok_or_else(|| "missing frame".to_owned())?.id;
let parent = add_task_subgoal(&TaskSubgoalAddOptions {
workspace_path: workspace.clone(),
frame_id: frame_id.clone(),
parent_id: None,
title: "Define schema".to_owned(),
status: TaskFrameStatus::Open,
blockers: Vec::new(),
created_at: Some("2026-05-04T00:01:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing parent subgoal".to_owned())?;
let child = add_task_subgoal(&TaskSubgoalAddOptions {
workspace_path: workspace,
frame_id,
parent_id: Some(parent.id.clone()),
title: "Add stable JSON".to_owned(),
status: TaskFrameStatus::Blocked,
blockers: vec!["needs CLI wiring".to_owned()],
created_at: Some("2026-05-04T00:02:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing child subgoal".to_owned())?;
assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
assert_eq!(child.status, TaskFrameStatus::Blocked);
Ok(())
}
#[test]
fn subgoal_parent_lookup_uses_normalized_parent_id() -> TestResult {
let workspace = temp_workspace("subgoal-parent-normalized")?;
let created =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame_id = created.frame.ok_or_else(|| "missing frame".to_owned())?.id;
let parent = add_task_subgoal(&TaskSubgoalAddOptions {
workspace_path: workspace.clone(),
frame_id: frame_id.clone(),
parent_id: None,
title: "Define schema".to_owned(),
status: TaskFrameStatus::Open,
blockers: Vec::new(),
created_at: Some("2026-05-04T00:01:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing parent subgoal".to_owned())?;
let child = add_task_subgoal(&TaskSubgoalAddOptions {
workspace_path: workspace,
frame_id,
parent_id: Some(format!(" {} ", parent.id)),
title: "Add stable JSON".to_owned(),
status: TaskFrameStatus::Open,
blockers: Vec::new(),
created_at: Some("2026-05-04T00:02:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing child subgoal".to_owned())?;
assert_eq!(child.parent_id.as_deref(), Some(parent.id.as_str()));
Ok(())
}
#[test]
fn whitespace_only_subgoal_parent_is_treated_as_absent() -> TestResult {
let workspace = temp_workspace("subgoal-parent-blank")?;
let created =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame_id = created.frame.ok_or_else(|| "missing frame".to_owned())?.id;
let subgoal = add_task_subgoal(&TaskSubgoalAddOptions {
workspace_path: workspace,
frame_id,
parent_id: Some(" \t ".to_owned()),
title: "Root subgoal".to_owned(),
status: TaskFrameStatus::Open,
blockers: Vec::new(),
created_at: Some("2026-05-04T00:01:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing subgoal".to_owned())?;
assert_eq!(subgoal.parent_id, None);
Ok(())
}
#[test]
fn subgoal_id_uses_normalized_stored_title() -> TestResult {
let workspace = temp_workspace("normalized-subgoal-id")?;
let created =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame_id = created.frame.ok_or_else(|| "missing frame".to_owned())?.id;
let raw = TaskSubgoalAddOptions {
workspace_path: workspace.clone(),
frame_id: frame_id.clone(),
parent_id: None,
title: " Remove private_key=abcdef ".to_owned(),
status: TaskFrameStatus::Open,
blockers: Vec::new(),
created_at: Some("2026-05-04T00:01:00Z".to_owned()),
dry_run: true,
};
let mut normalized = raw.clone();
normalized.title = format!(
"Remove private_key={}",
crate::policy::redaction_placeholder("private_key")
);
let raw_subgoal = add_task_subgoal(&raw)
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing raw subgoal".to_owned())?;
let normalized_subgoal = add_task_subgoal(&normalized)
.map_err(|e| e.message())?
.selected_subgoal
.ok_or_else(|| "missing normalized subgoal".to_owned())?;
assert_eq!(raw_subgoal.title, normalized_subgoal.title);
assert_eq!(raw_subgoal.id, normalized_subgoal.id);
Ok(())
}
#[test]
fn terminal_frame_cannot_be_reopened() -> TestResult {
let workspace = temp_workspace("terminal")?;
let created =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame_id = created.frame.ok_or_else(|| "missing frame".to_owned())?.id;
close_task_frame(&TaskFrameCloseOptions {
workspace_path: workspace.clone(),
frame_id: frame_id.clone(),
status: TaskFrameStatus::Completed,
reason: "done".to_owned(),
closed_at: Some("2026-05-04T00:03:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?;
let error = match update_task_frame(&TaskFrameUpdateOptions {
workspace_path: workspace,
frame_id,
status: Some(TaskFrameStatus::Active),
current_focus: None,
blockers: Vec::new(),
evidence_links: Vec::new(),
updated_at: Some("2026-05-04T00:04:00Z".to_owned()),
dry_run: false,
}) {
Ok(_) => return Err("terminal transition should be rejected".to_owned()),
Err(error) => error,
};
assert_eq!(error.code(), "policy_denied");
Ok(())
}
#[test]
fn active_selection_reports_ambiguous_scope() -> TestResult {
let workspace = temp_workspace("ambiguous")?;
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let mut second = create_options(workspace.clone());
second.goal = "Ship another frame".to_owned();
second.created_at = Some("2026-05-04T00:05:00Z".to_owned());
create_task_frame(&second).map_err(|e| e.message())?;
let error = match show_task_frame(&TaskFrameShowOptions {
workspace_path: workspace,
frame_id: None,
active: true,
}) {
Ok(_) => return Err("multiple active frames should be ambiguous".to_owned()),
Err(error) => error,
};
assert_eq!(error.code(), "usage");
Ok(())
}
#[test]
fn task_frame_redacts_secret_like_values_before_persisting() -> TestResult {
let workspace = temp_workspace("redaction")?;
let mut options = create_options(workspace.clone());
let raw_token = format!("{}{}", concat!("sk", "-ant", "-api03", "-"), "A".repeat(52));
let aws_key = format!("{}{}", concat!("AK", "IA"), "B".repeat(16));
let jwt = [
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
"eyJzdWIiOiIxMjM0NTY3ODkwIn0",
"Rq8IjqberX03cRIZHg7v0Rq8IjqberX03cRIZHg7v0",
]
.join(".");
let pem_body = concat!("MII", "Redact", "TaskFrame", "Body");
options.goal = format!("Rotate api_key=sk-live-123 and {raw_token} before release");
options.current_focus = Some(format!(
"Check DATABASE_URL=postgres://user:hunter2@example.test/db and AWS {aws_key}"
));
options.blockers = vec![
"needs password='open-sesame' from operator".to_owned(),
format!("Authorization: Bearer {jwt}"),
format!(
"Do not persist -----BEGIN PRIVATE KEY-----\n{pem_body}\n-----END PRIVATE KEY-----"
),
];
let report = create_task_frame(&options).map_err(|e| e.message())?;
let frame = report.frame.ok_or_else(|| "missing frame".to_owned())?;
let serialized = serde_json::to_string(&frame).map_err(|error| error.to_string())?;
assert_eq!(frame.redaction_status, "redacted");
assert!(serialized.contains(&crate::policy::redaction_placeholder("api_key")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("anthropic_api_key")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("aws_access_key")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("bearer_token")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("pem_block")));
assert!(!serialized.contains("sk-live-123"));
assert!(!serialized.contains(&raw_token));
assert!(!serialized.contains(&aws_key));
assert!(!serialized.contains(&jwt));
assert!(!serialized.contains(pem_body));
assert!(!serialized.contains("hunter2"));
assert!(!serialized.contains("open-sesame"));
assert!(task_frame_store_path(&workspace).exists());
Ok(())
}
#[test]
fn update_subgoal_and_close_redact_late_secret_inputs() -> TestResult {
let workspace = temp_workspace("redaction-update")?;
let created =
create_task_frame(&create_options(workspace.clone())).map_err(|e| e.message())?;
let frame_id = created.frame.ok_or_else(|| "missing frame".to_owned())?.id;
update_task_frame(&TaskFrameUpdateOptions {
workspace_path: workspace.clone(),
frame_id: frame_id.clone(),
status: Some(TaskFrameStatus::Active),
current_focus: Some("new token=abc123".to_owned()),
blockers: vec!["secret: hidden".to_owned()],
evidence_links: Vec::new(),
updated_at: Some("2026-05-04T00:06:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?;
add_task_subgoal(&TaskSubgoalAddOptions {
workspace_path: workspace.clone(),
frame_id: frame_id.clone(),
parent_id: None,
title: "remove private_key=abcdef".to_owned(),
status: TaskFrameStatus::Open,
blockers: Vec::new(),
created_at: Some("2026-05-04T00:07:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?;
let closed = close_task_frame(&TaskFrameCloseOptions {
workspace_path: workspace,
frame_id,
status: TaskFrameStatus::Completed,
reason: "completed after password=done".to_owned(),
closed_at: Some("2026-05-04T00:08:00Z".to_owned()),
dry_run: false,
})
.map_err(|e| e.message())?;
let frame = closed.frame.ok_or_else(|| "missing frame".to_owned())?;
let serialized = serde_json::to_string(&frame).map_err(|error| error.to_string())?;
assert_eq!(frame.redaction_status, "redacted");
assert!(serialized.contains(&crate::policy::redaction_placeholder("token")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("secret")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("private_key")));
assert!(serialized.contains(&crate::policy::redaction_placeholder("password")));
assert!(!serialized.contains("abc123"));
assert!(!serialized.contains("hidden"));
assert!(!serialized.contains("abcdef"));
assert!(!serialized.contains("done"));
Ok(())
}
#[test]
fn suggested_commands_use_existing_cli_shapes() {
let commands = suggested_commands(Some("tf_example"));
assert_eq!(
commands,
vec![
"ee task-frame show tf_example --json",
"ee focus suggest --task-frame tf_example --json",
]
);
assert!(
commands
.iter()
.all(|command| !command.contains("--task-frame-id"))
);
assert!(
commands
.iter()
.all(|command| !command.starts_with("ee handoff resume "))
);
}
}