@setoelkahfi / sigit / commits / 2d9a2dd

Add Tier-0 parity tools: multi_edit, glob, write_todos, remember

Closes several high-leverage gaps with Claude Code in the agent tool layer: - edit_file: add `replace_all` and return actionable failure context (names the line whose trimmed text matches when only whitespace differs), so the model self-corrects in one round instead of guessing. - multi_edit: apply a batch of exact-substring edits to one file atomically — the file is only written if every edit matches. - glob: locate files by name pattern (`**`, `*`, `?`, `{a,b}`), returning paths most-recently-modified first. - search_files: add `file_glob` filter and `max_results` (default 50, hard-capped at 1000); bound the walk by the requested limit. - write_todos: render a live task checklist via the tool result for multi-step task tracking. - remember: append durable notes to the nearest AGENTS.md/CLAUDE.md (new helper instructions::memory_file). All four tools flow through both the ACP and TUI spec builders via all_tools(). Adds unit tests; fmt + clippy (-D warnings) + test all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f6zfWDT1v5TeSpA29rEyx

Claude committed Jun 30, 2026 at 14:58 UTC 2d9a2dde51366557761bb02adc1a94893fc32975
2 files changed +801 -33
src/instructions.rs
+20
@@ -131,6 +131,26 @@ fn first_instruction_file(dir: &Path) -> Option<PathBuf> {
131 None
132 }
133
134 +/// The file the `remember` tool appends durable notes to: the nearest existing
135 +/// instruction file walking from `cwd` up to the repository root, or a new
136 +/// `CLAUDE.md` at the repo root (falling back to `cwd`) when none exists yet.
137 +pub fn memory_file(cwd: &Path) -> PathBuf {
138 + let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
139 + let root = repo_root(&canonical).unwrap_or_else(|| canonical.clone());
140 +
141 + // Deepest (most specific) existing file wins, matching read precedence.
142 + for dir in canonical
143 + .ancestors()
144 + .filter(|ancestor| ancestor.starts_with(&root))
145 + {
146 + if let Some(found) = first_instruction_file(dir) {
147 + return found;
148 + }
149 + }
150 +
151 + root.join("CLAUDE.md")
152 +}
153 +
154 fn sigit_config_dir() -> Option<PathBuf> {
155 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
156 && !dir.is_empty()
src/tools.rs
+781 -33
@@ -13,6 +13,9 @@ const WEBSITE_USER_AGENT: &str =
13
14 const READ_FILE_CHAR_LIMIT: usize = 10_000;
15 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
16 +/// Upper bound on `max_results` for `search_files` and the number of paths
17 +/// returned by `glob`, so a broad pattern can't flood the context window.
18 +const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
19
20 // ── Tool schemas ─────────────────────────────────────────────────────────────
21
@@ -93,7 +96,9 @@ pub fn all_tools() -> Vec<AgentTool> {
96 description: "Search for a regex pattern across files in a directory tree. \
97 Prefer an absolute root path when possible. Returns matching \
98 lines in `file:line_number: content` format. Skips binary \
96 - files and hidden directories. Limited to the first 50 matches.",
99 + files and hidden directories. Pass `file_glob` to restrict the \
100 + search to files whose name matches a glob (e.g. \"*.rs\"), and \
101 + `max_results` to raise or lower the default cap of 50 matches.",
102 parameters_schema: json!({
103 "type": "object",
104 "properties": {
@@ -104,6 +109,14 @@ pub fn all_tools() -> Vec<AgentTool> {
109 "path": {
110 "type": "string",
111 "description": "Root directory to search in. Defaults to \".\" (current directory)."
112 + },
113 + "file_glob": {
114 + "type": "string",
115 + "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
116 + },
117 + "max_results": {
118 + "type": "integer",
119 + "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
120 }
121 },
122 "required": ["pattern"],
@@ -154,11 +167,12 @@ pub fn all_tools() -> Vec<AgentTool> {
167 AgentTool {
168 name: "edit_file",
169 description: "Edit an existing file by replacing an exact substring (old_text) with \
157 - new text (new_text). Prefer an absolute path when possible. The \
158 - old_text must appear exactly once in the file. Use read_file first \
159 - to see the current content and identify the exact text to replace. \
160 - To append to a file, match the last few lines as old_text and \
161 - include them plus the new content as new_text.",
170 + new text (new_text). Prefer an absolute path when possible. By \
171 + default old_text must appear exactly once; set replace_all to true \
172 + to replace every occurrence (useful for renaming a symbol). Use \
173 + read_file first to see the current content and identify the exact \
174 + text to replace. To append to a file, match the last few lines as \
175 + old_text and include them plus the new content as new_text.",
176 parameters_schema: json!({
177 "type": "object",
178 "properties": {
@@ -168,11 +182,15 @@ pub fn all_tools() -> Vec<AgentTool> {
182 },
183 "old_text": {
184 "type": "string",
171 - "description": "The exact text span to find and replace. Must match exactly once."
185 + "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
186 },
187 "new_text": {
188 "type": "string",
189 "description": "The replacement text that will take the place of old_text."
190 + },
191 + "replace_all": {
192 + "type": "boolean",
193 + "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
194 }
195 },
196 "required": ["path", "old_text", "new_text"],
@@ -231,6 +249,130 @@ pub fn all_tools() -> Vec<AgentTool> {
249 "additionalProperties": false
250 }),
251 },
252 + AgentTool {
253 + name: "multi_edit",
254 + description: "Apply several exact-substring edits to a single file in one call. \
255 + Edits are applied in order, each to the result of the previous one, \
256 + and the whole batch is atomic — if any edit fails to match, the file \
257 + is left untouched and an error explains which edit failed. Prefer \
258 + this over multiple edit_file calls when changing several spots in the \
259 + same file. Each edit has old_text (must match exactly once, or every \
260 + time when replace_all is true) and new_text.",
261 + parameters_schema: json!({
262 + "type": "object",
263 + "properties": {
264 + "path": {
265 + "type": "string",
266 + "description": "Path to the existing file to edit."
267 + },
268 + "edits": {
269 + "type": "array",
270 + "description": "Ordered list of edits to apply to the file.",
271 + "items": {
272 + "type": "object",
273 + "properties": {
274 + "old_text": {
275 + "type": "string",
276 + "description": "The exact text span to find and replace."
277 + },
278 + "new_text": {
279 + "type": "string",
280 + "description": "The replacement text."
281 + },
282 + "replace_all": {
283 + "type": "boolean",
284 + "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
285 + }
286 + },
287 + "required": ["old_text", "new_text"],
288 + "additionalProperties": false
289 + }
290 + }
291 + },
292 + "required": ["path", "edits"],
293 + "additionalProperties": false
294 + }),
295 + },
296 + AgentTool {
297 + name: "glob",
298 + description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
299 + \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
300 + most-recently-modified first. Supports `*` (any run of non-separator \
301 + characters), `**` (any number of directories), `?` (one character), \
302 + and `{a,b}` alternation. Use this to locate files by name; use \
303 + search_files to search file contents.",
304 + parameters_schema: json!({
305 + "type": "object",
306 + "properties": {
307 + "pattern": {
308 + "type": "string",
309 + "description": "Glob pattern matched against paths relative to the search root."
310 + },
311 + "path": {
312 + "type": "string",
313 + "description": "Root directory to search in. Defaults to \".\" (current directory)."
314 + }
315 + },
316 + "required": ["pattern"],
317 + "additionalProperties": false
318 + }),
319 + },
320 + AgentTool {
321 + name: "write_todos",
322 + description: "Record or update a checklist of the steps for the current task. \
323 + Use this for any multi-step task to plan the work and show progress: \
324 + call it once up front with all the steps as `pending`, then call it \
325 + again whenever a step's status changes. Mark exactly one step \
326 + `in_progress` at a time and `completed` as soon as it is done. \
327 + Keep the list short and outcome-focused.",
328 + parameters_schema: json!({
329 + "type": "object",
330 + "properties": {
331 + "todos": {
332 + "type": "array",
333 + "description": "The full, current checklist (replaces any previous list).",
334 + "items": {
335 + "type": "object",
336 + "properties": {
337 + "content": {
338 + "type": "string",
339 + "description": "Short imperative description of the step."
340 + },
341 + "status": {
342 + "type": "string",
343 + "enum": ["pending", "in_progress", "completed"],
344 + "description": "Current status of the step."
345 + }
346 + },
347 + "required": ["content", "status"],
348 + "additionalProperties": false
349 + }
350 + }
351 + },
352 + "required": ["todos"],
353 + "additionalProperties": false
354 + }),
355 + },
356 + AgentTool {
357 + name: "remember",
358 + description: "Persist a durable note, preference, or convention by appending it to \
359 + this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
360 + when the user asks you to remember something for next time, or states \
361 + a lasting preference about how to work in this project. The note is \
362 + written to the nearest existing instruction file, or a new CLAUDE.md \
363 + at the repository root if none exists yet.",
364 + parameters_schema: json!({
365 + "type": "object",
366 + "properties": {
367 + "note": {
368 + "type": "string",
369 + "description": "The fact or preference to remember, phrased as a standalone instruction."
370 + }
371 + },
372 + "required": ["note"],
373 + "additionalProperties": false
374 + }),
375 + },
376 ]
377 }
378
@@ -251,6 +393,10 @@ pub async fn execute_tool(name: &str, arguments: &str) -> String {
393 "create_directory" => exec_create_directory(arguments),
394 "create_file" => exec_create_file(arguments),
395 "edit_file" => exec_edit_file(arguments),
396 + "multi_edit" => exec_multi_edit(arguments),
397 + "glob" => exec_glob(arguments),
398 + "write_todos" => exec_write_todos(arguments),
399 + "remember" => exec_remember(arguments),
400 "delete_file" => exec_delete_file(arguments),
401 "run_command" => exec_run_command(arguments),
402 "skill" => crate::skills::activate_skill(arguments),
@@ -428,6 +574,21 @@ fn exec_search_files(arguments: &str) -> String {
574 Err(err) => return format!("Error: invalid regex pattern: {err}"),
575 };
576
577 + // Optional file-name filter compiled from a glob (e.g. "*.rs").
578 + let name_filter = match args.get("file_glob").and_then(Value::as_str) {
579 + Some(glob) => match Regex::new(&glob_to_regex(glob)) {
580 + Ok(r) => Some(r),
581 + Err(err) => return format!("Error: invalid file_glob: {err}"),
582 + },
583 + None => None,
584 + };
585 +
586 + let limit = args
587 + .get("max_results")
588 + .and_then(Value::as_u64)
589 + .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
590 + .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
591 +
592 let root = Path::new(root_str);
593 let absolute_root = absolute_path(root);
594 let absolute_root_str = absolute_root.display().to_string();
@@ -441,27 +602,38 @@ fn exec_search_files(arguments: &str) -> String {
602 }
603
604 let mut matches: Vec<String> = Vec::new();
444 - walk_and_search(&absolute_root, &re, &mut matches);
605 + walk_and_search(
606 + &absolute_root,
607 + &re,
608 + name_filter.as_ref(),
609 + limit,
610 + &mut matches,
611 + );
612
613 if matches.is_empty() {
614 return format!("No matches found for pattern: {pattern_str}");
615 }
616
617 let total = matches.len();
451 - if total > SEARCH_FILES_MATCH_LIMIT {
452 - matches.truncate(SEARCH_FILES_MATCH_LIMIT);
618 + if total > limit {
619 + matches.truncate(limit);
620 matches.push(format!(
454 - "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
621 + "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
622 ));
623 }
624
625 matches.join("\n")
626 }
627
461 -/// caps collected matches at 2x the public limit to bound work on large trees.
462 -fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
463 - const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
464 -
628 +/// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
629 +/// pattern can't walk an entire tree once enough hits are found.
630 +fn walk_and_search(
631 + dir: &Path,
632 + re: &Regex,
633 + name_filter: Option<&Regex>,
634 + limit: usize,
635 + matches: &mut Vec<String>,
636 +) {
637 let entries = match fs::read_dir(dir) {
638 Ok(rd) => rd,
639 Err(_) => return,
@@ -471,7 +643,7 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
643 sorted.sort_by_key(|e| e.file_name());
644
645 for entry in sorted {
474 - if matches.len() >= WALK_CAP {
646 + if matches.len() > limit {
647 return;
648 }
649
@@ -484,8 +656,13 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
656 }
657
658 if path.is_dir() {
487 - walk_and_search(&path, re, matches);
659 + walk_and_search(&path, re, name_filter, limit, matches);
660 } else if path.is_file() {
661 + if let Some(filter) = name_filter
662 + && !filter.is_match(&name_str)
663 + {
664 + continue;
665 + }
666 search_file(&path, re, matches);
667 }
668 }
@@ -698,9 +875,68 @@ fn exec_create_file(arguments: &str) -> String {
875 }
876 }
877
701 -// ── edit_file ────────────────────────────────────────────────────────────────
878 +// ── edit_file / multi_edit ─────────────────────────────────────────────────
879 +
880 +/// Apply one exact-substring replacement to `contents`. Returns the updated
881 +/// string, or a human-readable explanation of why the match failed so the model
882 +/// can correct itself in a single follow-up instead of guessing blindly.
883 +fn apply_edit(
884 + contents: &str,
885 + old_text: &str,
886 + new_text: &str,
887 + replace_all: bool,
888 +) -> Result<String, String> {
889 + if old_text.is_empty() {
890 + return Err("old_text is empty; nothing to match".to_string());
891 + }
892 + if old_text == new_text {
893 + return Err("old_text and new_text are identical; no change to make".to_string());
894 + }
895 +
896 + let occurrences = contents.matches(old_text).count();
897 +
898 + if occurrences == 0 {
899 + return Err(format!(
900 + "old_text not found. Use read_file to copy the exact text \
901 + (including whitespace and indentation) to replace.{}",
902 + nearest_line_hint(contents, old_text)
903 + ));
904 + }
905 +
906 + if occurrences > 1 && !replace_all {
907 + return Err(format!(
908 + "old_text appears {occurrences} times; include more surrounding context so it \
909 + matches exactly once, or set replace_all to true to change every occurrence."
910 + ));
911 + }
912 +
913 + if replace_all {
914 + Ok(contents.replace(old_text, new_text))
915 + } else {
916 + Ok(contents.replacen(old_text, new_text, 1))
917 + }
918 +}
919 +
920 +/// When `old_text` doesn't match verbatim, point at the line whose trimmed text
921 +/// equals the first trimmed line of `old_text` — the usual culprit is a
922 +/// whitespace/indentation mismatch, and naming the line lets the model fix it.
923 +fn nearest_line_hint(contents: &str, old_text: &str) -> String {
924 + let first = old_text.lines().find(|l| !l.trim().is_empty());
925 + let Some(first) = first.map(str::trim) else {
926 + return String::new();
927 + };
928 + for (idx, line) in contents.lines().enumerate() {
929 + if line.trim() == first {
930 + return format!(
931 + " (the first line of old_text appears at line {}, so the difference is likely \
932 + whitespace or indentation)",
933 + idx + 1
934 + );
935 + }
936 + }
937 + String::new()
938 +}
939
703 -/// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first.
940 fn exec_edit_file(arguments: &str) -> String {
941 let args: Value = match serde_json::from_str(arguments) {
942 Ok(v) => v,
@@ -722,6 +958,11 @@ fn exec_edit_file(arguments: &str) -> String {
958 None => return "Error: missing required parameter \"new_text\"".to_string(),
959 };
960
961 + let replace_all = args
962 + .get("replace_all")
963 + .and_then(Value::as_bool)
964 + .unwrap_or(false);
965 +
966 let path = Path::new(path_str);
967 let absolute_path = absolute_path(path);
968 let absolute_path_str = absolute_path.display().to_string();
@@ -741,33 +982,360 @@ fn exec_edit_file(arguments: &str) -> String {
982 Err(err) => return format!("Error: could not read file: {err}"),
983 };
984
744 - let occurrences = contents.matches(old_text).count();
985 + let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
986 + Ok(updated) => updated,
987 + Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
988 + };
989
746 - if occurrences == 0 {
747 - return format!(
748 - "Error: old_text not found in {absolute_path_str}. \
749 - Use read_file to see the current content and copy the exact text to replace."
750 - );
990 + match fs::write(&absolute_path, &updated) {
991 + Ok(()) => format!(
992 + "Edited file: {absolute_path_str} ({} bytes written)",
993 + updated.len()
994 + ),
995 + Err(err) => format!("Error: could not write file: {err}"),
996 }
997 +}
998 +
999 +/// Apply a batch of edits to one file atomically: each edit is applied to the
1000 +/// result of the previous one, and the file is only written if *every* edit
1001 +/// matches. A failure leaves the file untouched.
1002 +fn exec_multi_edit(arguments: &str) -> String {
1003 + let args: Value = match serde_json::from_str(arguments) {
1004 + Ok(v) => v,
1005 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1006 + };
1007 +
1008 + let path_str = match args.get("path").and_then(Value::as_str) {
1009 + Some(p) => p,
1010 + None => return "Error: missing required parameter \"path\"".to_string(),
1011 + };
1012 +
1013 + let edits = match args.get("edits").and_then(Value::as_array) {
1014 + Some(e) if !e.is_empty() => e,
1015 + Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1016 + None => return "Error: missing required parameter \"edits\"".to_string(),
1017 + };
1018 +
1019 + let path = Path::new(path_str);
1020 + let absolute_path = absolute_path(path);
1021 + let absolute_path_str = absolute_path.display().to_string();
1022
753 - if occurrences > 1 {
1023 + if !absolute_path.exists() {
1024 return format!(
755 - "Error: old_text appears {occurrences} times in {absolute_path_str}. \
756 - Include more surrounding context in old_text so it matches exactly once."
1025 + "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1026 );
1027 }
1028
760 - let updated = contents.replacen(old_text, new_text, 1);
1029 + if !absolute_path.is_file() {
1030 + return format!("Error: path is not a file: {absolute_path_str}");
1031 + }
1032
762 - match fs::write(&absolute_path, &updated) {
1033 + let mut working = match fs::read_to_string(&absolute_path) {
1034 + Ok(c) => c,
1035 + Err(err) => return format!("Error: could not read file: {err}"),
1036 + };
1037 +
1038 + for (idx, edit) in edits.iter().enumerate() {
1039 + let old_text = match edit.get("old_text").and_then(Value::as_str) {
1040 + Some(t) => t,
1041 + None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1042 + };
1043 + let new_text = match edit.get("new_text").and_then(Value::as_str) {
1044 + Some(t) => t,
1045 + None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1046 + };
1047 + let replace_all = edit
1048 + .get("replace_all")
1049 + .and_then(Value::as_bool)
1050 + .unwrap_or(false);
1051 +
1052 + match apply_edit(&working, old_text, new_text, replace_all) {
1053 + Ok(updated) => working = updated,
1054 + Err(why) => {
1055 + return format!(
1056 + "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1057 + idx + 1
1058 + );
1059 + }
1060 + }
1061 + }
1062 +
1063 + match fs::write(&absolute_path, &working) {
1064 Ok(()) => format!(
764 - "Edited file: {absolute_path_str} ({} bytes written)",
765 - updated.len()
1065 + "Applied {} edits to {absolute_path_str} ({} bytes written)",
1066 + edits.len(),
1067 + working.len()
1068 ),
1069 Err(err) => format!("Error: could not write file: {err}"),
1070 }
1071 }
1072
1073 +// ── glob ─────────────────────────────────────────────────────────────────────
1074 +
1075 +/// Translate a shell-style glob into an anchored regex. Supports `*`
1076 +/// (non-separator run), `**` (any number of directories), `?` (one
1077 +/// non-separator), and `{a,b}` alternation; everything else is matched
1078 +/// literally. Used both by the `glob` tool (against relative paths) and by
1079 +/// `search_files`' `file_glob` filter (against bare file names).
1080 +fn glob_to_regex(glob: &str) -> String {
1081 + let chars: Vec<char> = glob.chars().collect();
1082 + let mut re = String::from("^");
1083 + let mut brace_depth = 0usize;
1084 + let mut i = 0;
1085 +
1086 + while i < chars.len() {
1087 + let c = chars[i];
1088 + match c {
1089 + '*' => {
1090 + if i + 1 < chars.len() && chars[i + 1] == '*' {
1091 + i += 1; // consume the second '*'
1092 + if i + 1 < chars.len() && chars[i + 1] == '/' {
1093 + // `**/` matches zero or more leading directories.
1094 + re.push_str("(?:.*/)?");
1095 + i += 1; // consume the '/'
1096 + } else {
1097 + re.push_str(".*");
1098 + }
1099 + } else {
1100 + re.push_str("[^/]*");
1101 + }
1102 + }
1103 + '?' => re.push_str("[^/]"),
1104 + '{' => {
1105 + brace_depth += 1;
1106 + re.push_str("(?:");
1107 + }
1108 + '}' if brace_depth > 0 => {
1109 + brace_depth -= 1;
1110 + re.push(')');
1111 + }
1112 + ',' if brace_depth > 0 => re.push('|'),
1113 + // Escape regex metacharacters so they match literally. (`{` is always
1114 + // consumed by the brace arm above; an unmatched `}` lands here.)
1115 + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1116 + re.push('\\');
1117 + re.push(c);
1118 + }
1119 + other => re.push(other),
1120 + }
1121 + i += 1;
1122 + }
1123 +
1124 + re.push('$');
1125 + re
1126 +}
1127 +
1128 +fn exec_glob(arguments: &str) -> String {
1129 + let args: Value = match serde_json::from_str(arguments) {
1130 + Ok(v) => v,
1131 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1132 + };
1133 +
1134 + let pattern = match args.get("pattern").and_then(Value::as_str) {
1135 + Some(p) => p,
1136 + None => return "Error: missing required parameter \"pattern\"".to_string(),
1137 + };
1138 +
1139 + let re = match Regex::new(&glob_to_regex(pattern)) {
1140 + Ok(r) => r,
1141 + Err(err) => return format!("Error: invalid glob pattern: {err}"),
1142 + };
1143 +
1144 + let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1145 + let absolute_root = absolute_path(Path::new(root_str));
1146 + let absolute_root_str = absolute_root.display().to_string();
1147 +
1148 + if !absolute_root.exists() {
1149 + return format!("Error: path does not exist: {absolute_root_str}");
1150 + }
1151 + if !absolute_root.is_dir() {
1152 + return format!("Error: path is not a directory: {absolute_root_str}");
1153 + }
1154 +
1155 + let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1156 + glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1157 +
1158 + if found.is_empty() {
1159 + return format!("No files match glob: {pattern}");
1160 + }
1161 +
1162 + // Most-recently-modified first, like Claude Code's Glob.
1163 + found.sort_by(|a, b| b.0.cmp(&a.0));
1164 +
1165 + let total = found.len();
1166 + let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1167 + if total > SEARCH_RESULTS_HARD_CAP {
1168 + paths.truncate(SEARCH_RESULTS_HARD_CAP);
1169 + paths.push(format!(
1170 + "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1171 + ));
1172 + }
1173 +
1174 + paths.join("\n")
1175 +}
1176 +
1177 +fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1178 + if out.len() > SEARCH_RESULTS_HARD_CAP {
1179 + return;
1180 + }
1181 +
1182 + let entries = match fs::read_dir(dir) {
1183 + Ok(rd) => rd,
1184 + Err(_) => return,
1185 + };
1186 +
1187 + let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1188 + sorted.sort_by_key(|e| e.file_name());
1189 +
1190 + for entry in sorted {
1191 + if out.len() > SEARCH_RESULTS_HARD_CAP {
1192 + return;
1193 + }
1194 +
1195 + let path = entry.path();
1196 + let name = entry.file_name();
1197 + if name.to_string_lossy().starts_with('.') {
1198 + continue;
1199 + }
1200 +
1201 + if path.is_dir() {
1202 + glob_walk(root, &path, re, out);
1203 + } else if path.is_file() {
1204 + let relative = path
1205 + .strip_prefix(root)
1206 + .unwrap_or(&path)
1207 + .to_string_lossy()
1208 + .replace('\\', "/");
1209 + if re.is_match(&relative) {
1210 + let mtime = entry
1211 + .metadata()
1212 + .and_then(|m| m.modified())
1213 + .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1214 + out.push((mtime, absolute_path_string(&path)));
1215 + }
1216 + }
1217 + }
1218 +}
1219 +
1220 +// ── write_todos ──────────────────────────────────────────────────────────────
1221 +
1222 +/// Renders the model's task checklist back as the tool result so the surface
1223 +/// (TUI / ACP client) can show live progress. Pure presentation — the list is
1224 +/// owned by the model, not persisted here.
1225 +fn exec_write_todos(arguments: &str) -> String {
1226 + let args: Value = match serde_json::from_str(arguments) {
1227 + Ok(v) => v,
1228 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1229 + };
1230 +
1231 + let todos = match args.get("todos").and_then(Value::as_array) {
1232 + Some(t) if !t.is_empty() => t,
1233 + Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1234 + None => return "Error: missing required parameter \"todos\"".to_string(),
1235 + };
1236 +
1237 + let mut lines = Vec::with_capacity(todos.len());
1238 + let mut completed = 0usize;
1239 +
1240 + for (idx, todo) in todos.iter().enumerate() {
1241 + let content = match todo.get("content").and_then(Value::as_str) {
1242 + Some(c) => c.trim(),
1243 + None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1244 + };
1245 + let status = todo
1246 + .get("status")
1247 + .and_then(Value::as_str)
1248 + .unwrap_or("pending");
1249 +
1250 + let marker = match status {
1251 + "completed" => {
1252 + completed += 1;
1253 + "[x]"
1254 + }
1255 + "in_progress" => "[~]",
1256 + _ => "[ ]",
1257 + };
1258 + lines.push(format!("{marker} {content}"));
1259 + }
1260 +
1261 + format!(
1262 + "Task list updated ({completed}/{} done):\n{}",
1263 + todos.len(),
1264 + lines.join("\n")
1265 + )
1266 +}
1267 +
1268 +// ── remember ─────────────────────────────────────────────────────────────────
1269 +
1270 +/// Appends a durable note to the project's instruction file so it persists
1271 +/// across sessions (the always-on counterpart to a one-off chat message).
1272 +fn exec_remember(arguments: &str) -> String {
1273 + let args: Value = match serde_json::from_str(arguments) {
1274 + Ok(v) => v,
1275 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1276 + };
1277 +
1278 + let note = match args.get("note").and_then(Value::as_str) {
1279 + Some(n) if !n.trim().is_empty() => n.trim(),
1280 + Some(_) => return "Error: \"note\" must not be empty".to_string(),
1281 + None => return "Error: missing required parameter \"note\"".to_string(),
1282 + };
1283 +
1284 + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1285 + remember_at(&cwd, note)
1286 +}
1287 +
1288 +/// Core of `remember`, parameterized on the working directory so it can be
1289 +/// tested without mutating the process-global current directory.
1290 +fn remember_at(cwd: &Path, note: &str) -> String {
1291 + let target = crate::instructions::memory_file(cwd);
1292 + let target_str = target.display().to_string();
1293 +
1294 + let existed = target.exists();
1295 + let mut body = if existed {
1296 + match fs::read_to_string(&target) {
1297 + Ok(c) => c,
1298 + Err(err) => return format!("Error: could not read {target_str}: {err}"),
1299 + }
1300 + } else {
1301 + if let Some(parent) = target.parent()
1302 + && !parent.as_os_str().is_empty()
1303 + && !parent.exists()
1304 + && let Err(err) = fs::create_dir_all(parent)
1305 + {
1306 + return format!("Error: could not create parent directories: {err}");
1307 + }
1308 + String::new()
1309 + };
1310 +
1311 + // Keep remembered notes grouped under one heading so the file stays tidy.
1312 + const HEADING: &str = "## Remembered notes";
1313 + if !body.contains(HEADING) {
1314 + if !body.is_empty() && !body.ends_with('\n') {
1315 + body.push('\n');
1316 + }
1317 + if !body.is_empty() {
1318 + body.push('\n');
1319 + }
1320 + body.push_str(HEADING);
1321 + body.push('\n');
1322 + }
1323 + if !body.ends_with('\n') {
1324 + body.push('\n');
1325 + }
1326 + body.push_str("- ");
1327 + body.push_str(note);
1328 + body.push('\n');
1329 +
1330 + match fs::write(&target, &body) {
1331 + Ok(()) => {
1332 + let verb = if existed { "Appended to" } else { "Created" };
1333 + format!("{verb} {target_str}: remembered \"{note}\"")
1334 + }
1335 + Err(err) => format!("Error: could not write {target_str}: {err}"),
1336 + }
1337 +}
1338 +
1339 // ── delete_file ──────────────────────────────────────────────────────────────
1340
1341 /// only removes files or *empty* directories — no recursive deletes.
@@ -1028,7 +1596,7 @@ mod tests {
1596 #[test]
1597 fn test_all_tools_count() {
1598 let tools = all_tools();
1031 - assert_eq!(tools.len(), 9);
1599 + assert_eq!(tools.len(), 13);
1600 assert_eq!(tools[0].name, "read_file");
1601 assert_eq!(tools[1].name, "create_directory");
1602 assert_eq!(tools[2].name, "list_directory");
@@ -1038,6 +1606,186 @@ mod tests {
1606 assert_eq!(tools[6].name, "edit_file");
1607 assert_eq!(tools[7].name, "delete_file");
1608 assert_eq!(tools[8].name, "run_command");
1609 + assert_eq!(tools[9].name, "multi_edit");
1610 + assert_eq!(tools[10].name, "glob");
1611 + assert_eq!(tools[11].name, "write_todos");
1612 + assert_eq!(tools[12].name, "remember");
1613 + }
1614 +
1615 + #[test]
1616 + fn test_edit_file_replace_all() {
1617 + let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1618 + let _ = fs::remove_dir_all(&dir);
1619 + fs::create_dir_all(&dir).unwrap();
1620 + let file = dir.join("f.txt");
1621 + fs::write(&file, "foo foo foo").unwrap();
1622 +
1623 + // Without replace_all an ambiguous match is rejected.
1624 + let args =
1625 + serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1626 + let result = exec_edit_file(&args);
1627 + assert!(result.contains("appears 3 times"), "{result}");
1628 +
1629 + // With replace_all every occurrence is changed.
1630 + let args = serde_json::json!({
1631 + "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1632 + })
1633 + .to_string();
1634 + let result = exec_edit_file(&args);
1635 + assert!(result.starts_with("Edited file:"), "{result}");
1636 + assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1637 +
1638 + let _ = fs::remove_dir_all(&dir);
1639 + }
1640 +
1641 + #[test]
1642 + fn test_edit_file_whitespace_hint() {
1643 + let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1644 + let _ = fs::remove_dir_all(&dir);
1645 + fs::create_dir_all(&dir).unwrap();
1646 + let file = dir.join("f.txt");
1647 + fs::write(&file, "line one\n indented\nline three\n").unwrap();
1648 +
1649 + // old_text has more indentation than the file, so it isn't a substring,
1650 + // but its trimmed content still locates the intended line.
1651 + let args = serde_json::json!({
1652 + "path": &file, "old_text": " indented", "new_text": "x"
1653 + })
1654 + .to_string();
1655 + let result = exec_edit_file(&args);
1656 + assert!(result.contains("line 2"), "{result}");
1657 + assert!(result.contains("whitespace"), "{result}");
1658 +
1659 + let _ = fs::remove_dir_all(&dir);
1660 + }
1661 +
1662 + #[test]
1663 + fn test_multi_edit_atomic_on_failure() {
1664 + let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1665 + let _ = fs::remove_dir_all(&dir);
1666 + fs::create_dir_all(&dir).unwrap();
1667 + let file = dir.join("f.txt");
1668 + fs::write(&file, "alpha beta gamma").unwrap();
1669 +
1670 + // Second edit can't match -> nothing should be written.
1671 + let args = serde_json::json!({
1672 + "path": &file,
1673 + "edits": [
1674 + { "old_text": "alpha", "new_text": "ALPHA" },
1675 + { "old_text": "nope", "new_text": "x" }
1676 + ]
1677 + })
1678 + .to_string();
1679 + let result = exec_multi_edit(&args);
1680 + assert!(result.contains("edit #2 failed"), "{result}");
1681 + assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1682 +
1683 + // All-matching batch applies in sequence.
1684 + let args = serde_json::json!({
1685 + "path": &file,
1686 + "edits": [
1687 + { "old_text": "alpha", "new_text": "ALPHA" },
1688 + { "old_text": "gamma", "new_text": "GAMMA" }
1689 + ]
1690 + })
1691 + .to_string();
1692 + let result = exec_multi_edit(&args);
1693 + assert!(result.contains("Applied 2 edits"), "{result}");
1694 + assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1695 +
1696 + let _ = fs::remove_dir_all(&dir);
1697 + }
1698 +
1699 + #[test]
1700 + fn test_glob_to_regex() {
1701 + let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1702 + assert!(re.is_match("src/tools.rs"));
1703 + assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1704 + assert!(!re.is_match("src/tools.txt"));
1705 +
1706 + let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1707 + assert!(re.is_match("app.ts"));
1708 + assert!(re.is_match("app.tsx"));
1709 + assert!(!re.is_match("app.js"));
1710 + }
1711 +
1712 + #[test]
1713 + fn test_glob_tool_success() {
1714 + let dir = std::env::temp_dir().join("sigit_test_glob");
1715 + let _ = fs::remove_dir_all(&dir);
1716 + fs::create_dir_all(dir.join("src")).unwrap();
1717 + fs::write(dir.join("Cargo.toml"), "").unwrap();
1718 + fs::write(dir.join("src/main.rs"), "").unwrap();
1719 + fs::write(dir.join("src/lib.rs"), "").unwrap();
1720 +
1721 + let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1722 + let result = exec_glob(&args);
1723 + assert!(result.contains("main.rs"), "{result}");
1724 + assert!(result.contains("lib.rs"), "{result}");
1725 + assert!(!result.contains("Cargo.toml"), "{result}");
1726 +
1727 + let _ = fs::remove_dir_all(&dir);
1728 + }
1729 +
1730 + #[test]
1731 + fn test_search_files_file_glob_filter() {
1732 + let dir = std::env::temp_dir().join("sigit_test_search_glob");
1733 + let _ = fs::remove_dir_all(&dir);
1734 + fs::create_dir_all(&dir).unwrap();
1735 + fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1736 + fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1737 +
1738 + let args = serde_json::json!({
1739 + "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1740 + })
1741 + .to_string();
1742 + let result = exec_search_files(&args);
1743 + assert!(result.contains("code.rs"), "{result}");
1744 + assert!(!result.contains("notes.txt"), "{result}");
1745 +
1746 + let _ = fs::remove_dir_all(&dir);
1747 + }
1748 +
1749 + #[test]
1750 + fn test_write_todos_renders_checklist() {
1751 + let args = serde_json::json!({
1752 + "todos": [
1753 + { "content": "Read code", "status": "completed" },
1754 + { "content": "Make change", "status": "in_progress" },
1755 + { "content": "Run tests", "status": "pending" }
1756 + ]
1757 + })
1758 + .to_string();
1759 + let result = exec_write_todos(&args);
1760 + assert!(result.contains("1/3 done"), "{result}");
1761 + assert!(result.contains("[x] Read code"), "{result}");
1762 + assert!(result.contains("[~] Make change"), "{result}");
1763 + assert!(result.contains("[ ] Run tests"), "{result}");
1764 + }
1765 +
1766 + #[test]
1767 + fn test_remember_appends_to_instruction_file() {
1768 + let dir = std::env::temp_dir().join("sigit_test_remember");
1769 + let _ = fs::remove_dir_all(&dir);
1770 + fs::create_dir_all(dir.join(".git")).unwrap();
1771 + let claude_md = dir.join("CLAUDE.md");
1772 + fs::write(&claude_md, "# Project\n").unwrap();
1773 +
1774 + let target = crate::instructions::memory_file(&dir);
1775 + // Should pick the existing CLAUDE.md at the repo root.
1776 + assert_eq!(
1777 + target.canonicalize().unwrap(),
1778 + claude_md.canonicalize().unwrap()
1779 + );
1780 +
1781 + let result = remember_at(&dir, "remembered text");
1782 + assert!(result.contains("remembered"), "{result}");
1783 +
1784 + let updated = fs::read_to_string(&claude_md).unwrap();
1785 + assert!(updated.contains("## Remembered notes"), "{updated}");
1786 + assert!(updated.contains("- remembered text"), "{updated}");
1787 +
1788 + let _ = fs::remove_dir_all(&dir);
1789 }
1790
1791 #[test]