1 Commits

Author SHA1 Message Date
Yeachan-Heo
549deb9a89 Preserve local project context across compaction and todo updates
This change makes compaction summaries durable under .claude/memory,
feeds those saved memory files back into prompt context, updates /memory
to report both instruction and project-memory files, and moves TodoWrite
persistence to a human-readable .claude/todos.md file.

Constraint: Reuse existing compaction, prompt loading, and slash-command plumbing rather than add a new subsystem
Constraint: Keep persisted project state under Claude-local .claude/ paths
Rejected: Introduce a dedicated memory service module | larger diff with no clear user benefit for this task
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Project memory files are loaded as prompt context, so future format changes must preserve concise readable content
Tested: cargo fmt --all --manifest-path rust/Cargo.toml
Tested: cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D warnings
Tested: cargo test --manifest-path rust/Cargo.toml --all
Not-tested: Long-term retention/cleanup policy for .claude/memory growth
2026-04-01 00:58:36 +00:00
10 changed files with 344 additions and 378 deletions

View File

@@ -133,7 +133,6 @@ Inside the REPL, useful commands include:
/diff /diff
/version /version
/export notes.txt /export notes.txt
/sessions
/session list /session list
/exit /exit
``` ```
@@ -144,14 +143,14 @@ Inspect or maintain a saved session file without entering the REPL:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost
``` ```
You can also inspect memory/config state for a restored session: You can also inspect memory/config state for a restored session:
```bash ```bash
cd rust cd rust
cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config cargo run -p rusty-claude-cli -- --resume session.json /memory /config
``` ```
## Available commands ## Available commands
@@ -159,7 +158,7 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
### Top-level CLI commands ### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively - `prompt <text...>` — run one prompt non-interactively
- `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/` - `--resume <session.json> [/commands...]` — inspect or maintain a saved session
- `dump-manifests` — print extracted upstream manifest counts - `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton - `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt - `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
@@ -177,14 +176,13 @@ cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions - `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session - `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals - `/cost` — show token usage totals
- `/resume <session-id-or-path>` — load a saved session into the REPL - `/resume <session-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config - `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files - `/memory` — inspect loaded instruction memory files
- `/init` — create a starter `CLAUDE.md` - `/init` — create a starter `CLAUDE.md`
- `/diff` — show the current git diff for the workspace - `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally - `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript - `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions - `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL - `/exit` — leave the REPL

View File

@@ -84,7 +84,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec { SlashCommandSpec {
name: "resume", name: "resume",
summary: "Load a saved session into the REPL", summary: "Load a saved session into the REPL",
argument_hint: Some("<session-id-or-path>"), argument_hint: Some("<session-path>"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec { SlashCommandSpec {
@@ -129,12 +129,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: Some("[list|switch <session-id>]"), argument_hint: Some("[list|switch <session-id>]"),
resume_supported: false, resume_supported: false,
}, },
SlashCommandSpec {
name: "sessions",
summary: "List recent managed local sessions",
argument_hint: None,
resume_supported: false,
},
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -169,7 +163,6 @@ pub enum SlashCommand {
action: Option<String>, action: Option<String>,
target: Option<String>, target: Option<String>,
}, },
Sessions,
Unknown(String), Unknown(String),
} }
@@ -214,7 +207,6 @@ impl SlashCommand {
action: parts.next().map(ToOwned::to_owned), action: parts.next().map(ToOwned::to_owned),
target: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned),
}, },
"sessions" => Self::Sessions,
other => Self::Unknown(other.to_string()), other => Self::Unknown(other.to_string()),
}) })
} }
@@ -299,7 +291,6 @@ pub fn handle_slash_command(
| SlashCommand::Version | SlashCommand::Version
| SlashCommand::Export { .. } | SlashCommand::Export { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None,
} }
} }
@@ -374,10 +365,6 @@ mod tests {
target: Some("abc123".to_string()) target: Some("abc123".to_string())
}) })
); );
assert_eq!(
SlashCommand::parse("/sessions"),
Some(SlashCommand::Sessions)
);
} }
#[test] #[test]
@@ -391,7 +378,7 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-id-or-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
@@ -399,8 +386,7 @@ mod tests {
assert!(help.contains("/version")); assert!(help.contains("/version"));
assert!(help.contains("/export [file]")); assert!(help.contains("/export [file]"));
assert!(help.contains("/session [list|switch <session-id>]")); assert!(help.contains("/session [list|switch <session-id>]"));
assert!(help.contains("/sessions")); assert_eq!(slash_command_specs().len(), 15);
assert_eq!(slash_command_specs().len(), 16);
assert_eq!(resume_supported_slash_commands().len(), 11); assert_eq!(resume_supported_slash_commands().len(), 11);
} }
@@ -418,7 +404,6 @@ mod tests {
text: "recent".to_string(), text: "recent".to_string(),
}]), }]),
], ],
metadata: None,
}; };
let result = handle_slash_command( let result = handle_slash_command(
@@ -483,6 +468,5 @@ mod tests {
assert!( assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
); );
assert!(handle_slash_command("/sessions", &session, CompactionConfig::default()).is_none());
} }
} }

View File

@@ -1,3 +1,6 @@
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -90,6 +93,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
let preserved = session.messages[keep_from..].to_vec(); let preserved = session.messages[keep_from..].to_vec();
let summary = summarize_messages(removed); let summary = summarize_messages(removed);
let formatted_summary = format_compact_summary(&summary); let formatted_summary = format_compact_summary(&summary);
persist_compact_summary(&formatted_summary);
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty()); let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
let mut compacted_messages = vec![ConversationMessage { let mut compacted_messages = vec![ConversationMessage {
@@ -105,12 +109,40 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
compacted_session: Session { compacted_session: Session {
version: session.version, version: session.version,
messages: compacted_messages, messages: compacted_messages,
metadata: session.metadata.clone(),
}, },
removed_message_count: removed.len(), removed_message_count: removed.len(),
} }
} }
fn persist_compact_summary(formatted_summary: &str) {
if formatted_summary.trim().is_empty() {
return;
}
let Ok(cwd) = std::env::current_dir() else {
return;
};
let memory_dir = cwd.join(".claude").join("memory");
if fs::create_dir_all(&memory_dir).is_err() {
return;
}
let path = memory_dir.join(compact_summary_filename());
let _ = fs::write(path, render_memory_file(formatted_summary));
}
fn compact_summary_filename() -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("summary-{timestamp}.md")
}
fn render_memory_file(formatted_summary: &str) -> String {
format!("# Project memory\n\n{}\n", formatted_summary.trim())
}
fn summarize_messages(messages: &[ConversationMessage]) -> String { fn summarize_messages(messages: &[ConversationMessage]) -> String {
let user_messages = messages let user_messages = messages
.iter() .iter()
@@ -379,14 +411,21 @@ fn collapse_blank_lines(content: &str) -> String {
mod tests { mod tests {
use super::{ use super::{
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary, collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
infer_pending_work, should_compact, CompactionConfig, infer_pending_work, render_memory_file, should_compact, CompactionConfig,
}; };
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session}; use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
#[test] #[test]
fn formats_compact_summary_like_upstream() { fn formats_compact_summary_like_upstream() {
let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>"; let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
assert_eq!(format_compact_summary(summary), "Summary:\nKept work"); assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
assert_eq!(
render_memory_file("Summary:\nKept work"),
"# Project memory\n\nSummary:\nKept work\n"
);
} }
#[test] #[test]
@@ -394,7 +433,6 @@ mod tests {
let session = Session { let session = Session {
version: 1, version: 1,
messages: vec![ConversationMessage::user_text("hello")], messages: vec![ConversationMessage::user_text("hello")],
metadata: None,
}; };
let result = compact_session(&session, CompactionConfig::default()); let result = compact_session(&session, CompactionConfig::default());
@@ -404,6 +442,63 @@ mod tests {
assert!(result.formatted_summary.is_empty()); assert!(result.formatted_summary.is_empty());
} }
#[test]
fn persists_compacted_summaries_under_dot_claude_memory() {
let _guard = crate::test_env_lock();
let temp = std::env::temp_dir().join(format!(
"runtime-compact-memory-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time after epoch")
.as_nanos()
));
fs::create_dir_all(&temp).expect("temp dir");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&temp).expect("set cwd");
let session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
],
};
let result = compact_session(
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
},
);
let memory_dir = temp.join(".claude").join("memory");
let files = fs::read_dir(&memory_dir)
.expect("memory dir exists")
.flatten()
.map(|entry| entry.path())
.collect::<Vec<_>>();
assert_eq!(result.removed_message_count, 2);
assert_eq!(files.len(), 1);
let persisted = fs::read_to_string(&files[0]).expect("memory file readable");
std::env::set_current_dir(previous).expect("restore cwd");
fs::remove_dir_all(temp).expect("cleanup temp dir");
assert!(persisted.contains("# Project memory"));
assert!(persisted.contains("Summary:"));
}
#[test] #[test]
fn compacts_older_messages_into_a_system_summary() { fn compacts_older_messages_into_a_system_summary() {
let session = Session { let session = Session {
@@ -422,7 +517,6 @@ mod tests {
usage: None, usage: None,
}, },
], ],
metadata: None,
}; };
let result = compact_session( let result = compact_session(

View File

@@ -415,6 +415,7 @@ mod tests {
current_date: "2026-03-31".to_string(), current_date: "2026-03-31".to_string(),
git_status: None, git_status: None,
instruction_files: Vec::new(), instruction_files: Vec::new(),
memory_files: Vec::new(),
}) })
.with_os("linux", "6.8") .with_os("linux", "6.8")
.build(); .build();

View File

@@ -73,9 +73,7 @@ pub use remote::{
RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL,
DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS,
}; };
pub use session::{ pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError};
ContentBlock, ConversationMessage, MessageRole, Session, SessionError, SessionMetadata,
};
pub use usage::{ pub use usage::{
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
}; };

View File

@@ -51,6 +51,7 @@ pub struct ProjectContext {
pub current_date: String, pub current_date: String,
pub git_status: Option<String>, pub git_status: Option<String>,
pub instruction_files: Vec<ContextFile>, pub instruction_files: Vec<ContextFile>,
pub memory_files: Vec<ContextFile>,
} }
impl ProjectContext { impl ProjectContext {
@@ -60,11 +61,13 @@ impl ProjectContext {
) -> std::io::Result<Self> { ) -> std::io::Result<Self> {
let cwd = cwd.into(); let cwd = cwd.into();
let instruction_files = discover_instruction_files(&cwd)?; let instruction_files = discover_instruction_files(&cwd)?;
let memory_files = discover_memory_files(&cwd)?;
Ok(Self { Ok(Self {
cwd, cwd,
current_date: current_date.into(), current_date: current_date.into(),
git_status: None, git_status: None,
instruction_files, instruction_files,
memory_files,
}) })
} }
@@ -144,6 +147,9 @@ impl SystemPromptBuilder {
if !project_context.instruction_files.is_empty() { if !project_context.instruction_files.is_empty() {
sections.push(render_instruction_files(&project_context.instruction_files)); sections.push(render_instruction_files(&project_context.instruction_files));
} }
if !project_context.memory_files.is_empty() {
sections.push(render_memory_files(&project_context.memory_files));
}
} }
if let Some(config) = &self.config { if let Some(config) = &self.config {
sections.push(render_config_section(config)); sections.push(render_config_section(config));
@@ -186,7 +192,7 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect() items.into_iter().map(|item| format!(" - {item}")).collect()
} }
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> { fn discover_context_directories(cwd: &Path) -> Vec<PathBuf> {
let mut directories = Vec::new(); let mut directories = Vec::new();
let mut cursor = Some(cwd); let mut cursor = Some(cwd);
while let Some(dir) = cursor { while let Some(dir) = cursor {
@@ -194,6 +200,11 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
cursor = dir.parent(); cursor = dir.parent();
} }
directories.reverse(); directories.reverse();
directories
}
fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
let directories = discover_context_directories(cwd);
let mut files = Vec::new(); let mut files = Vec::new();
for dir in directories { for dir in directories {
@@ -209,6 +220,26 @@ fn discover_instruction_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
Ok(dedupe_instruction_files(files)) Ok(dedupe_instruction_files(files))
} }
fn discover_memory_files(cwd: &Path) -> std::io::Result<Vec<ContextFile>> {
let mut files = Vec::new();
for dir in discover_context_directories(cwd) {
let memory_dir = dir.join(".claude").join("memory");
let Ok(entries) = fs::read_dir(&memory_dir) else {
continue;
};
let mut paths = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file())
.collect::<Vec<_>>();
paths.sort();
for path in paths {
push_context_file(&mut files, path)?;
}
}
Ok(dedupe_instruction_files(files))
}
fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> { fn push_context_file(files: &mut Vec<ContextFile>, path: PathBuf) -> std::io::Result<()> {
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => { Ok(content) if !content.trim().is_empty() => {
@@ -251,6 +282,12 @@ fn render_project_context(project_context: &ProjectContext) -> String {
project_context.instruction_files.len() project_context.instruction_files.len()
)); ));
} }
if !project_context.memory_files.is_empty() {
bullets.push(format!(
"Project memory files discovered: {}.",
project_context.memory_files.len()
));
}
lines.extend(prepend_bullets(bullets)); lines.extend(prepend_bullets(bullets));
if let Some(status) = &project_context.git_status { if let Some(status) = &project_context.git_status {
lines.push(String::new()); lines.push(String::new());
@@ -261,7 +298,15 @@ fn render_project_context(project_context: &ProjectContext) -> String {
} }
fn render_instruction_files(files: &[ContextFile]) -> String { fn render_instruction_files(files: &[ContextFile]) -> String {
let mut sections = vec!["# Claude instructions".to_string()]; render_context_file_section("# Claude instructions", files)
}
fn render_memory_files(files: &[ContextFile]) -> String {
render_context_file_section("# Project memory", files)
}
fn render_context_file_section(title: &str, files: &[ContextFile]) -> String {
let mut sections = vec![title.to_string()];
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS; let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files { for file in files {
if remaining_chars == 0 { if remaining_chars == 0 {
@@ -453,8 +498,9 @@ fn get_actions_section() -> String {
mod tests { mod tests {
use super::{ use super::{
collapse_blank_lines, display_context_path, normalize_instruction_content, collapse_blank_lines, display_context_path, normalize_instruction_content,
render_instruction_content, render_instruction_files, truncate_instruction_content, render_instruction_content, render_instruction_files, render_memory_files,
ContextFile, ProjectContext, SystemPromptBuilder, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, truncate_instruction_content, ContextFile, ProjectContext, SystemPromptBuilder,
SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
}; };
use crate::config::ConfigLoader; use crate::config::ConfigLoader;
use std::fs; use std::fs;
@@ -519,6 +565,35 @@ mod tests {
fs::remove_dir_all(root).expect("cleanup temp dir"); fs::remove_dir_all(root).expect("cleanup temp dir");
} }
#[test]
fn discovers_project_memory_files_from_ancestor_chain() {
let root = temp_dir();
let nested = root.join("apps").join("api");
fs::create_dir_all(root.join(".claude").join("memory")).expect("root memory dir");
fs::create_dir_all(nested.join(".claude").join("memory")).expect("nested memory dir");
fs::write(
root.join(".claude").join("memory").join("2026-03-30.md"),
"root memory",
)
.expect("write root memory");
fs::write(
nested.join(".claude").join("memory").join("2026-03-31.md"),
"nested memory",
)
.expect("write nested memory");
let context = ProjectContext::discover(&nested, "2026-03-31").expect("context should load");
let contents = context
.memory_files
.iter()
.map(|file| file.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["root memory", "nested memory"]);
assert!(render_memory_files(&context.memory_files).contains("# Project memory"));
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test] #[test]
fn dedupes_identical_instruction_content_across_scopes() { fn dedupes_identical_instruction_content_across_scopes() {
let root = temp_dir(); let root = temp_dir();

View File

@@ -39,19 +39,10 @@ pub struct ConversationMessage {
pub usage: Option<TokenUsage>, pub usage: Option<TokenUsage>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionMetadata {
pub started_at: String,
pub model: String,
pub message_count: u32,
pub last_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session { pub struct Session {
pub version: u32, pub version: u32,
pub messages: Vec<ConversationMessage>, pub messages: Vec<ConversationMessage>,
pub metadata: Option<SessionMetadata>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -91,7 +82,6 @@ impl Session {
Self { Self {
version: 1, version: 1,
messages: Vec::new(), messages: Vec::new(),
metadata: None,
} }
} }
@@ -121,9 +111,6 @@ impl Session {
.collect(), .collect(),
), ),
); );
if let Some(metadata) = &self.metadata {
object.insert("metadata".to_string(), metadata.to_json());
}
JsonValue::Object(object) JsonValue::Object(object)
} }
@@ -144,15 +131,7 @@ impl Session {
.iter() .iter()
.map(ConversationMessage::from_json) .map(ConversationMessage::from_json)
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let metadata = object Ok(Self { version, messages })
.get("metadata")
.map(SessionMetadata::from_json)
.transpose()?;
Ok(Self {
version,
messages,
metadata,
})
} }
} }
@@ -162,41 +141,6 @@ impl Default for Session {
} }
} }
impl SessionMetadata {
#[must_use]
pub fn to_json(&self) -> JsonValue {
let mut object = BTreeMap::new();
object.insert(
"started_at".to_string(),
JsonValue::String(self.started_at.clone()),
);
object.insert("model".to_string(), JsonValue::String(self.model.clone()));
object.insert(
"message_count".to_string(),
JsonValue::Number(i64::from(self.message_count)),
);
if let Some(last_prompt) = &self.last_prompt {
object.insert(
"last_prompt".to_string(),
JsonValue::String(last_prompt.clone()),
);
}
JsonValue::Object(object)
}
fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
let object = value.as_object().ok_or_else(|| {
SessionError::Format("session metadata must be an object".to_string())
})?;
Ok(Self {
started_at: required_string(object, "started_at")?,
model: required_string(object, "model")?,
message_count: required_u32(object, "message_count")?,
last_prompt: optional_string(object, "last_prompt"),
})
}
}
impl ConversationMessage { impl ConversationMessage {
#[must_use] #[must_use]
pub fn user_text(text: impl Into<String>) -> Self { pub fn user_text(text: impl Into<String>) -> Self {
@@ -424,13 +368,6 @@ fn required_string(
.ok_or_else(|| SessionError::Format(format!("missing {key}"))) .ok_or_else(|| SessionError::Format(format!("missing {key}")))
} }
fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
object
.get(key)
.and_then(JsonValue::as_str)
.map(ToOwned::to_owned)
}
fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> { fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
let value = object let value = object
.get(key) .get(key)
@@ -441,8 +378,7 @@ fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata}; use super::{ContentBlock, ConversationMessage, MessageRole, Session};
use crate::json::JsonValue;
use crate::usage::TokenUsage; use crate::usage::TokenUsage;
use std::fs; use std::fs;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -450,12 +386,6 @@ mod tests {
#[test] #[test]
fn persists_and_restores_session_json() { fn persists_and_restores_session_json() {
let mut session = Session::new(); let mut session = Session::new();
session.metadata = Some(SessionMetadata {
started_at: "2026-04-01T00:00:00Z".to_string(),
model: "claude-sonnet".to_string(),
message_count: 3,
last_prompt: Some("hello".to_string()),
});
session session
.messages .messages
.push(ConversationMessage::user_text("hello")); .push(ConversationMessage::user_text("hello"));
@@ -498,23 +428,5 @@ mod tests {
restored.messages[1].usage.expect("usage").total_tokens(), restored.messages[1].usage.expect("usage").total_tokens(),
17 17
); );
assert_eq!(restored.metadata, session.metadata);
}
#[test]
fn loads_legacy_session_without_metadata() {
let legacy = r#"{
"version": 1,
"messages": [
{
"role": "user",
"blocks": [{"type": "text", "text": "hello"}]
}
]
}"#;
let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
.expect("legacy session should parse");
assert_eq!(restored.messages.len(), 1);
assert!(restored.metadata.is_none());
} }
} }

View File

@@ -300,7 +300,6 @@ mod tests {
cache_read_input_tokens: 0, cache_read_input_tokens: 0,
}), }),
}], }],
metadata: None,
}; };
let tracker = UsageTracker::from_session(&session); let tracker = UsageTracker::from_session(&session);

View File

@@ -27,7 +27,7 @@ use runtime::{
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, SessionMetadata, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
use serde_json::json; use serde_json::json;
use tools::{execute_tool, mvp_tool_specs, ToolSpec}; use tools::{execute_tool, mvp_tool_specs, ToolSpec};
@@ -37,7 +37,6 @@ const DEFAULT_MAX_TOKENS: u32 = 32;
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const OLD_SESSION_COMPACTION_AGE_SECS: u64 = 60 * 60 * 24;
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -536,14 +535,7 @@ fn print_version() {
} }
fn resume_session(session_path: &Path, commands: &[String]) { fn resume_session(session_path: &Path, commands: &[String]) {
let handle = match resolve_session_reference(&session_path.display().to_string()) { let session = match Session::load_from_path(session_path) {
Ok(handle) => handle,
Err(error) => {
eprintln!("failed to resolve session: {error}");
std::process::exit(1);
}
};
let session = match Session::load_from_path(&handle.path) {
Ok(session) => session, Ok(session) => session,
Err(error) => { Err(error) => {
eprintln!("failed to restore session: {error}"); eprintln!("failed to restore session: {error}");
@@ -554,7 +546,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
if commands.is_empty() { if commands.is_empty() {
println!( println!(
"Restored session from {} ({} messages).", "Restored session from {} ({} messages).",
handle.path.display(), session_path.display(),
session.messages.len() session.messages.len()
); );
return; return;
@@ -566,7 +558,7 @@ fn resume_session(session_path: &Path, commands: &[String]) {
eprintln!("unsupported resumed command: {raw_command}"); eprintln!("unsupported resumed command: {raw_command}");
std::process::exit(2); std::process::exit(2);
}; };
match run_resume_command(&handle.path, &session, &command) { match run_resume_command(session_path, &session, &command) {
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: next_session, session: next_session,
message, message,
@@ -891,7 +883,6 @@ fn run_resume_command(
| SlashCommand::Model { .. } | SlashCommand::Model { .. }
| SlashCommand::Permissions { .. } | SlashCommand::Permissions { .. }
| SlashCommand::Session { .. } | SlashCommand::Session { .. }
| SlashCommand::Sessions
| SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()),
} }
} }
@@ -948,9 +939,6 @@ struct ManagedSessionSummary {
path: PathBuf, path: PathBuf,
modified_epoch_secs: u64, modified_epoch_secs: u64,
message_count: usize, message_count: usize,
model: Option<String>,
started_at: Option<String>,
last_prompt: Option<String>,
} }
struct LiveCli { struct LiveCli {
@@ -971,7 +959,6 @@ impl LiveCli {
) -> Result<Self, Box<dyn std::error::Error>> { ) -> Result<Self, Box<dyn std::error::Error>> {
let system_prompt = build_system_prompt()?; let system_prompt = build_system_prompt()?;
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
auto_compact_inactive_sessions(&session.id)?;
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
model.clone(), model.clone(),
@@ -1143,10 +1130,6 @@ impl LiveCli {
SlashCommand::Session { action, target } => { SlashCommand::Session { action, target } => {
self.handle_session_command(action.as_deref(), target.as_deref())? self.handle_session_command(action.as_deref(), target.as_deref())?
} }
SlashCommand::Sessions => {
println!("{}", render_session_list(&self.session.id)?);
false
}
SlashCommand::Unknown(name) => { SlashCommand::Unknown(name) => {
eprintln!("unknown slash command: /{name}"); eprintln!("unknown slash command: /{name}");
false false
@@ -1155,10 +1138,7 @@ impl LiveCli {
} }
fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> { fn persist_session(&self) -> Result<(), Box<dyn std::error::Error>> {
let mut session = self.runtime.session().clone(); self.runtime.session().save_to_path(&self.session.path)?;
session.metadata = Some(derive_session_metadata(&session, &self.model));
session.save_to_path(&self.session.path)?;
auto_compact_inactive_sessions(&self.session.id)?;
Ok(()) Ok(())
} }
@@ -1303,20 +1283,13 @@ impl LiveCli {
session_path: Option<String>, session_path: Option<String>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let Some(session_ref) = session_path else { let Some(session_ref) = session_path else {
println!("Usage: /resume <session-id-or-path>"); println!("Usage: /resume <session-path>");
return Ok(false); return Ok(false);
}; };
let handle = resolve_session_reference(&session_ref)?; let handle = resolve_session_reference(&session_ref)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1393,13 +1366,6 @@ impl LiveCli {
let handle = resolve_session_reference(target)?; let handle = resolve_session_reference(target)?;
let session = Session::load_from_path(&handle.path)?; let session = Session::load_from_path(&handle.path)?;
let message_count = session.messages.len(); let message_count = session.messages.len();
if let Some(model) = session
.metadata
.as_ref()
.map(|metadata| metadata.model.clone())
{
self.model = model;
}
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
self.model.clone(), self.model.clone(),
@@ -1444,10 +1410,8 @@ impl LiveCli {
} }
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> { fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
let home = env::var_os("HOME") let cwd = env::current_dir()?;
.map(PathBuf::from) let path = cwd.join(".claude").join("sessions");
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set"))?;
let path = home.join(".claude").join("sessions");
fs::create_dir_all(&path)?; fs::create_dir_all(&path)?;
Ok(path) Ok(path)
} }
@@ -1468,19 +1432,8 @@ fn generate_session_id() -> String {
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> { fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let direct = PathBuf::from(reference); let direct = PathBuf::from(reference);
let expanded = if let Some(stripped) = reference.strip_prefix("~/") {
sessions_dir()?
.parent()
.and_then(|claude| claude.parent())
.map(|home| home.join(stripped))
.unwrap_or(direct.clone())
} else {
direct.clone()
};
let path = if direct.exists() { let path = if direct.exists() {
direct direct
} else if expanded.exists() {
expanded
} else { } else {
sessions_dir()?.join(format!("{reference}.json")) sessions_dir()?.join(format!("{reference}.json"))
}; };
@@ -1510,11 +1463,9 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or_default(); .unwrap_or_default();
let session = Session::load_from_path(&path).ok(); let message_count = Session::load_from_path(&path)
let derived_message_count = session.as_ref().map_or(0, |session| session.messages.len()); .map(|session| session.messages.len())
let stored = session .unwrap_or_default();
.as_ref()
.and_then(|session| session.metadata.as_ref());
let id = path let id = path
.file_stem() .file_stem()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
@@ -1524,12 +1475,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
id, id,
path, path,
modified_epoch_secs, modified_epoch_secs,
message_count: stored.map_or(derived_message_count, |metadata| { message_count,
metadata.message_count as usize
}),
model: stored.map(|metadata| metadata.model.clone()),
started_at: stored.map(|metadata| metadata.started_at.clone()),
last_prompt: stored.and_then(|metadata| metadata.last_prompt.clone()),
}); });
} }
sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs)); sessions.sort_by(|left, right| right.modified_epoch_secs.cmp(&left.modified_epoch_secs));
@@ -1552,99 +1498,17 @@ fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::e
} else { } else {
"○ saved" "○ saved"
}; };
let model = session.model.as_deref().unwrap_or("unknown");
let started = session.started_at.as_deref().unwrap_or("unknown");
let last_prompt = session.last_prompt.as_deref().map_or_else(
|| "-".to_string(),
|prompt| truncate_for_summary(prompt, 36),
);
lines.push(format!( lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} model={model:<24} started={started} modified={modified} last={last_prompt} path={path}", " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified} path={path}",
id = session.id, id = session.id,
msgs = session.message_count, msgs = session.message_count,
model = model,
started = started,
modified = session.modified_epoch_secs, modified = session.modified_epoch_secs,
last_prompt = last_prompt,
path = session.path.display(), path = session.path.display(),
)); ));
} }
Ok(lines.join("\n")) Ok(lines.join("\n"))
} }
fn current_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
fn current_timestamp_rfc3339ish() -> String {
format!("{}Z", current_epoch_secs())
}
fn last_prompt_from_session(session: &Session) -> Option<String> {
session
.messages
.iter()
.rev()
.find(|message| message.role == MessageRole::User)
.and_then(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
_ => None,
})
})
.filter(|text| !text.is_empty())
}
fn derive_session_metadata(session: &Session, model: &str) -> SessionMetadata {
let started_at = session
.metadata
.as_ref()
.map_or_else(current_timestamp_rfc3339ish, |metadata| {
metadata.started_at.clone()
});
SessionMetadata {
started_at,
model: model.to_string(),
message_count: session.messages.len().try_into().unwrap_or(u32::MAX),
last_prompt: last_prompt_from_session(session),
}
}
fn session_age_secs(modified_epoch_secs: u64) -> u64 {
current_epoch_secs().saturating_sub(modified_epoch_secs)
}
fn auto_compact_inactive_sessions(
active_session_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
for summary in list_managed_sessions()? {
if summary.id == active_session_id
|| session_age_secs(summary.modified_epoch_secs) < OLD_SESSION_COMPACTION_AGE_SECS
{
continue;
}
let path = summary.path.clone();
let Ok(session) = Session::load_from_path(&path) else {
continue;
};
if !runtime::should_compact(&session, CompactionConfig::default()) {
continue;
}
let mut compacted =
runtime::compact_session(&session, CompactionConfig::default()).compacted_session;
let model = compacted.metadata.as_ref().map_or_else(
|| DEFAULT_MODEL.to_string(),
|metadata| metadata.model.clone(),
);
compacted.metadata = Some(derive_session_metadata(&compacted, &model));
compacted.save_to_path(&path)?;
}
Ok(())
}
fn render_repl_help() -> String { fn render_repl_help() -> String {
[ [
"REPL".to_string(), "REPL".to_string(),
@@ -1678,7 +1542,8 @@ fn status_context(
session_path: session_path.map(Path::to_path_buf), session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(), loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files, discovered_config_files,
memory_file_count: project_context.instruction_files.len(), memory_file_count: project_context.instruction_files.len()
+ project_context.memory_files.len(),
project_root, project_root,
git_branch, git_branch,
}) })
@@ -1823,39 +1688,58 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let mut lines = vec![format!( let mut lines = vec![format!(
"Memory "Memory
Working directory {} Working directory {}
Instruction files {}", Instruction files {}
Project memory files {}",
cwd.display(), cwd.display(),
project_context.instruction_files.len() project_context.instruction_files.len(),
project_context.memory_files.len()
)]; )];
if project_context.instruction_files.is_empty() { append_memory_section(
lines.push("Discovered files".to_string()); &mut lines,
lines.push( "Instruction files",
" No CLAUDE instruction files discovered in the current directory ancestry." &project_context.instruction_files,
.to_string(), "No CLAUDE instruction files discovered in the current directory ancestry.",
); );
} else { append_memory_section(
lines.push("Discovered files".to_string()); &mut lines,
for (index, file) in project_context.instruction_files.iter().enumerate() { "Project memory files",
let preview = file.content.lines().next().unwrap_or("").trim(); &project_context.memory_files,
let preview = if preview.is_empty() { "No persisted project memory files discovered in .claude/memory.",
"<empty>" );
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
Ok(lines.join( Ok(lines.join(
" "
", ",
)) ))
} }
fn append_memory_section(
lines: &mut Vec<String>,
title: &str,
files: &[runtime::ContextFile],
empty_message: &str,
) {
lines.push(title.to_string());
if files.is_empty() {
lines.push(format!(" {empty_message}"));
return;
}
for (index, file) in files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() {
"<empty>"
} else {
preview
};
lines.push(format!(" {}. {}", index + 1, file.path.display()));
lines.push(format!(
" lines={} preview={}",
file.content.lines().count(),
preview
));
}
}
fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> { fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let claude_md = cwd.join("CLAUDE.md"); let claude_md = cwd.join("CLAUDE.md");
@@ -2525,73 +2409,17 @@ fn print_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
derive_session_metadata, filter_tool_specs, format_compact_report, format_cost_report, filter_tool_specs, format_compact_report, format_cost_report, format_init_report,
format_init_report, format_model_report, format_model_switch_report, format_model_report, format_model_switch_report, format_permissions_report,
format_permissions_report, format_permissions_switch_report, format_resume_report, format_permissions_switch_report, format_resume_report, format_status_report,
format_status_report, format_tool_call_start, format_tool_result, list_managed_sessions, format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args,
normalize_permission_mode, parse_args, parse_git_status_metadata, render_config_report, parse_git_status_metadata, render_config_report, render_init_claude_md,
render_init_claude_md, render_memory_report, render_repl_help, render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
resume_supported_slash_commands, sessions_dir, status_context, CliAction, CliOutputFormat, CliAction, CliOutputFormat, SlashCommand, StatusUsage, DEFAULT_MODEL,
SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode, Session}; use runtime::{ContentBlock, ConversationMessage, MessageRole, PermissionMode};
use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[test]
fn derive_session_metadata_recomputes_prompt_and_count() {
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("first prompt"));
session
.messages
.push(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "reply".to_string(),
}]));
let metadata = derive_session_metadata(&session, "claude-test");
assert_eq!(metadata.model, "claude-test");
assert_eq!(metadata.message_count, 2);
assert_eq!(metadata.last_prompt.as_deref(), Some("first prompt"));
assert!(metadata.started_at.ends_with('Z'));
}
#[test]
fn managed_sessions_use_home_directory_and_list_metadata() {
let temp =
std::env::temp_dir().join(format!("rusty-claude-cli-home-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
fs::create_dir_all(&temp).expect("temp home should exist");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", &temp);
let dir = sessions_dir().expect("sessions dir");
assert_eq!(dir, temp.join(".claude").join("sessions"));
let mut session = Session::new();
session
.messages
.push(ConversationMessage::user_text("persist me"));
session.metadata = Some(derive_session_metadata(&session, "claude-home"));
let file = dir.join("session-test.json");
session.save_to_path(&file).expect("session save");
let listed = list_managed_sessions().expect("session list");
let found = listed
.into_iter()
.find(|entry| entry.id == "session-test")
.expect("saved session should be listed");
assert_eq!(found.message_count, 1);
assert_eq!(found.model.as_deref(), Some("claude-home"));
assert_eq!(found.last_prompt.as_deref(), Some("persist me"));
fs::remove_file(file).ok();
if let Some(previous_home) = previous_home {
std::env::set_var("HOME", previous_home);
}
fs::remove_dir_all(temp).ok();
}
#[test] #[test]
fn defaults_to_repl_when_no_args() { fn defaults_to_repl_when_no_args() {
assert_eq!( assert_eq!(
@@ -2797,8 +2625,7 @@ mod tests {
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-id-or-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/sessions"));
assert!(help.contains("/config [env|hooks|model]")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
@@ -2965,7 +2792,7 @@ mod tests {
assert!(report.contains("Memory")); assert!(report.contains("Memory"));
assert!(report.contains("Working directory")); assert!(report.contains("Working directory"));
assert!(report.contains("Instruction files")); assert!(report.contains("Instruction files"));
assert!(report.contains("Discovered files")); assert!(report.contains("Project memory files"));
} }
#[test] #[test]

View File

@@ -1199,10 +1199,9 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
validate_todos(&input.todos)?; validate_todos(&input.todos)?;
let store_path = todo_store_path()?; let store_path = todo_store_path()?;
let old_todos = if store_path.exists() { let old_todos = if store_path.exists() {
serde_json::from_str::<Vec<TodoItem>>( parse_todo_markdown(
&std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?, &std::fs::read_to_string(&store_path).map_err(|error| error.to_string())?,
) )?
.map_err(|error| error.to_string())?
} else { } else {
Vec::new() Vec::new()
}; };
@@ -1220,11 +1219,8 @@ fn execute_todo_write(input: TodoWriteInput) -> Result<TodoWriteOutput, String>
if let Some(parent) = store_path.parent() { if let Some(parent) = store_path.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
} }
std::fs::write( std::fs::write(&store_path, render_todo_markdown(&persisted))
&store_path, .map_err(|error| error.to_string())?;
serde_json::to_string_pretty(&persisted).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let verification_nudge_needed = (all_done let verification_nudge_needed = (all_done
&& input.todos.len() >= 3 && input.todos.len() >= 3
@@ -1282,7 +1278,58 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
return Ok(std::path::PathBuf::from(path)); return Ok(std::path::PathBuf::from(path));
} }
let cwd = std::env::current_dir().map_err(|error| error.to_string())?; let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
Ok(cwd.join(".clawd-todos.json")) Ok(cwd.join(".claude").join("todos.md"))
}
fn render_todo_markdown(todos: &[TodoItem]) -> String {
let mut lines = vec!["# Todo list".to_string(), String::new()];
for todo in todos {
let marker = match todo.status {
TodoStatus::Pending => "[ ]",
TodoStatus::InProgress => "[~]",
TodoStatus::Completed => "[x]",
};
lines.push(format!(
"- {marker} {} :: {}",
todo.content, todo.active_form
));
}
lines.push(String::new());
lines.join("\n")
}
fn parse_todo_markdown(content: &str) -> Result<Vec<TodoItem>, String> {
let mut todos = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some(rest) = trimmed.strip_prefix("- [") else {
continue;
};
let mut chars = rest.chars();
let status = match chars.next() {
Some(' ') => TodoStatus::Pending,
Some('~') => TodoStatus::InProgress,
Some('x' | 'X') => TodoStatus::Completed,
Some(other) => return Err(format!("unsupported todo status marker: {other}")),
None => return Err(String::from("malformed todo line")),
};
let remainder = chars.as_str();
let Some(body) = remainder.strip_prefix("] ") else {
return Err(String::from("malformed todo line"));
};
let Some((content, active_form)) = body.split_once(" :: ") else {
return Err(String::from("todo line missing active form separator"));
};
todos.push(TodoItem {
content: content.trim().to_string(),
active_form: active_form.trim().to_string(),
status,
});
}
Ok(todos)
} }
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> { fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
@@ -2638,6 +2685,37 @@ mod tests {
assert!(second_output["verificationNudgeNeeded"].is_null()); assert!(second_output["verificationNudgeNeeded"].is_null());
} }
#[test]
fn todo_write_persists_markdown_in_claude_directory() {
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let temp = temp_path("todos-md-dir");
std::fs::create_dir_all(&temp).expect("temp dir");
let previous = std::env::current_dir().expect("cwd");
std::env::set_current_dir(&temp).expect("set cwd");
execute_tool(
"TodoWrite",
&json!({
"todos": [
{"content": "Add tool", "activeForm": "Adding tool", "status": "in_progress"},
{"content": "Run tests", "activeForm": "Running tests", "status": "pending"}
]
}),
)
.expect("TodoWrite should succeed");
let persisted = std::fs::read_to_string(temp.join(".claude").join("todos.md"))
.expect("todo markdown exists");
std::env::set_current_dir(previous).expect("restore cwd");
let _ = std::fs::remove_dir_all(temp);
assert!(persisted.contains("# Todo list"));
assert!(persisted.contains("- [~] Add tool :: Adding tool"));
assert!(persisted.contains("- [ ] Run tests :: Running tests"));
}
#[test] #[test]
fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() { fn todo_write_rejects_invalid_payloads_and_sets_verification_nudge() {
let _guard = env_lock() let _guard = env_lock()