@setoelkahfi / sigit / commits / 3cc86aa

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 3cc86aae7c48f8d930fd78889aeef30c7a2e0f80
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),
@@ -425,6 +571,21 @@ fn exec_search_files(arguments: &str) -> String {
571 Err(err) => return format!("Error: invalid regex pattern: {err}"),
572 };
573
574 + // Optional file-name filter compiled from a glob (e.g. "*.rs").
575 + let name_filter = match args.get("file_glob").and_then(Value::as_str) {
576 + Some(glob) => match Regex::new(&glob_to_regex(glob)) {
577 + Ok(r) => Some(r),
578 + Err(err) => return format!("Error: invalid file_glob: {err}"),
579 + },
580 + None => None,
581 + };
582 +
583 + let limit = args
584 + .get("max_results")
585 + .and_then(Value::as_u64)
586 + .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
587 + .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
588 +
589 let root = Path::new(root_str);
590 let absolute_root = absolute_path(root);
591 let absolute_root_str = absolute_root.display().to_string();
@@ -438,27 +599,38 @@ fn exec_search_files(arguments: &str) -> String {
599 }
600
601 let mut matches: Vec<String> = Vec::new();
441 - walk_and_search(&absolute_root, &re, &mut matches);
602 + walk_and_search(
603 + &absolute_root,
604 + &re,
605 + name_filter.as_ref(),
606 + limit,
607 + &mut matches,
608 + );
609
610 if matches.is_empty() {
611 return format!("No matches found for pattern: {pattern_str}");
612 }
613
614 let total = matches.len();
448 - if total > SEARCH_FILES_MATCH_LIMIT {
449 - matches.truncate(SEARCH_FILES_MATCH_LIMIT);
615 + if total > limit {
616 + matches.truncate(limit);
617 matches.push(format!(
451 - "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
618 + "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
619 ));
620 }
621
622 matches.join("\n")
623 }
624
458 -/// caps collected matches at 2x the public limit to bound work on large trees.
459 -fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
460 - const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
461 -
625 +/// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
626 +/// pattern can't walk an entire tree once enough hits are found.
627 +fn walk_and_search(
628 + dir: &Path,
629 + re: &Regex,
630 + name_filter: Option<&Regex>,
631 + limit: usize,
632 + matches: &mut Vec<String>,
633 +) {
634 let entries = match fs::read_dir(dir) {
635 Ok(rd) => rd,
636 Err(_) => return,
@@ -468,7 +640,7 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
640 sorted.sort_by_key(|e| e.file_name());
641
642 for entry in sorted {
471 - if matches.len() >= WALK_CAP {
643 + if matches.len() > limit {
644 return;
645 }
646
@@ -481,8 +653,13 @@ fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
653 }
654
655 if path.is_dir() {
484 - walk_and_search(&path, re, matches);
656 + walk_and_search(&path, re, name_filter, limit, matches);
657 } else if path.is_file() {
658 + if let Some(filter) = name_filter
659 + && !filter.is_match(&name_str)
660 + {
661 + continue;
662 + }
663 search_file(&path, re, matches);
664 }
665 }
@@ -695,9 +872,68 @@ fn exec_create_file(arguments: &str) -> String {
872 }
873 }
874
698 -// ── edit_file ────────────────────────────────────────────────────────────────
875 +// ── edit_file / multi_edit ─────────────────────────────────────────────────
876 +
877 +/// Apply one exact-substring replacement to `contents`. Returns the updated
878 +/// string, or a human-readable explanation of why the match failed so the model
879 +/// can correct itself in a single follow-up instead of guessing blindly.
880 +fn apply_edit(
881 + contents: &str,
882 + old_text: &str,
883 + new_text: &str,
884 + replace_all: bool,
885 +) -> Result<String, String> {
886 + if old_text.is_empty() {
887 + return Err("old_text is empty; nothing to match".to_string());
888 + }
889 + if old_text == new_text {
890 + return Err("old_text and new_text are identical; no change to make".to_string());
891 + }
892 +
893 + let occurrences = contents.matches(old_text).count();
894 +
895 + if occurrences == 0 {
896 + return Err(format!(
897 + "old_text not found. Use read_file to copy the exact text \
898 + (including whitespace and indentation) to replace.{}",
899 + nearest_line_hint(contents, old_text)
900 + ));
901 + }
902 +
903 + if occurrences > 1 && !replace_all {
904 + return Err(format!(
905 + "old_text appears {occurrences} times; include more surrounding context so it \
906 + matches exactly once, or set replace_all to true to change every occurrence."
907 + ));
908 + }
909 +
910 + if replace_all {
911 + Ok(contents.replace(old_text, new_text))
912 + } else {
913 + Ok(contents.replacen(old_text, new_text, 1))
914 + }
915 +}
916 +
917 +/// When `old_text` doesn't match verbatim, point at the line whose trimmed text
918 +/// equals the first trimmed line of `old_text` — the usual culprit is a
919 +/// whitespace/indentation mismatch, and naming the line lets the model fix it.
920 +fn nearest_line_hint(contents: &str, old_text: &str) -> String {
921 + let first = old_text.lines().find(|l| !l.trim().is_empty());
922 + let Some(first) = first.map(str::trim) else {
923 + return String::new();
924 + };
925 + for (idx, line) in contents.lines().enumerate() {
926 + if line.trim() == first {
927 + return format!(
928 + " (the first line of old_text appears at line {}, so the difference is likely \
929 + whitespace or indentation)",
930 + idx + 1
931 + );
932 + }
933 + }
934 + String::new()
935 +}
936
700 -/// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first.
937 fn exec_edit_file(arguments: &str) -> String {
938 let args: Value = match serde_json::from_str(arguments) {
939 Ok(v) => v,
@@ -719,6 +955,11 @@ fn exec_edit_file(arguments: &str) -> String {
955 None => return "Error: missing required parameter \"new_text\"".to_string(),
956 };
957
958 + let replace_all = args
959 + .get("replace_all")
960 + .and_then(Value::as_bool)
961 + .unwrap_or(false);
962 +
963 let path = Path::new(path_str);
964 let absolute_path = absolute_path(path);
965 let absolute_path_str = absolute_path.display().to_string();
@@ -738,33 +979,360 @@ fn exec_edit_file(arguments: &str) -> String {
979 Err(err) => return format!("Error: could not read file: {err}"),
980 };
981
741 - let occurrences = contents.matches(old_text).count();
982 + let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
983 + Ok(updated) => updated,
984 + Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
985 + };
986
743 - if occurrences == 0 {
744 - return format!(
745 - "Error: old_text not found in {absolute_path_str}. \
746 - Use read_file to see the current content and copy the exact text to replace."
747 - );
987 + match fs::write(&absolute_path, &updated) {
988 + Ok(()) => format!(
989 + "Edited file: {absolute_path_str} ({} bytes written)",
990 + updated.len()
991 + ),
992 + Err(err) => format!("Error: could not write file: {err}"),
993 }
994 +}
995 +
996 +/// Apply a batch of edits to one file atomically: each edit is applied to the
997 +/// result of the previous one, and the file is only written if *every* edit
998 +/// matches. A failure leaves the file untouched.
999 +fn exec_multi_edit(arguments: &str) -> String {
1000 + let args: Value = match serde_json::from_str(arguments) {
1001 + Ok(v) => v,
1002 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1003 + };
1004 +
1005 + let path_str = match args.get("path").and_then(Value::as_str) {
1006 + Some(p) => p,
1007 + None => return "Error: missing required parameter \"path\"".to_string(),
1008 + };
1009 +
1010 + let edits = match args.get("edits").and_then(Value::as_array) {
1011 + Some(e) if !e.is_empty() => e,
1012 + Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1013 + None => return "Error: missing required parameter \"edits\"".to_string(),
1014 + };
1015 +
1016 + let path = Path::new(path_str);
1017 + let absolute_path = absolute_path(path);
1018 + let absolute_path_str = absolute_path.display().to_string();
1019
750 - if occurrences > 1 {
1020 + if !absolute_path.exists() {
1021 return format!(
752 - "Error: old_text appears {occurrences} times in {absolute_path_str}. \
753 - Include more surrounding context in old_text so it matches exactly once."
1022 + "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1023 );
1024 }
1025
757 - let updated = contents.replacen(old_text, new_text, 1);
1026 + if !absolute_path.is_file() {
1027 + return format!("Error: path is not a file: {absolute_path_str}");
1028 + }
1029
759 - match fs::write(&absolute_path, &updated) {
1030 + let mut working = match fs::read_to_string(&absolute_path) {
1031 + Ok(c) => c,
1032 + Err(err) => return format!("Error: could not read file: {err}"),
1033 + };
1034 +
1035 + for (idx, edit) in edits.iter().enumerate() {
1036 + let old_text = match edit.get("old_text").and_then(Value::as_str) {
1037 + Some(t) => t,
1038 + None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1039 + };
1040 + let new_text = match edit.get("new_text").and_then(Value::as_str) {
1041 + Some(t) => t,
1042 + None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1043 + };
1044 + let replace_all = edit
1045 + .get("replace_all")
1046 + .and_then(Value::as_bool)
1047 + .unwrap_or(false);
1048 +
1049 + match apply_edit(&working, old_text, new_text, replace_all) {
1050 + Ok(updated) => working = updated,
1051 + Err(why) => {
1052 + return format!(
1053 + "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1054 + idx + 1
1055 + );
1056 + }
1057 + }
1058 + }
1059 +
1060 + match fs::write(&absolute_path, &working) {
1061 Ok(()) => format!(
761 - "Edited file: {absolute_path_str} ({} bytes written)",
762 - updated.len()
1062 + "Applied {} edits to {absolute_path_str} ({} bytes written)",
1063 + edits.len(),
1064 + working.len()
1065 ),
1066 Err(err) => format!("Error: could not write file: {err}"),
1067 }
1068 }
1069
1070 +// ── glob ─────────────────────────────────────────────────────────────────────
1071 +
1072 +/// Translate a shell-style glob into an anchored regex. Supports `*`
1073 +/// (non-separator run), `**` (any number of directories), `?` (one
1074 +/// non-separator), and `{a,b}` alternation; everything else is matched
1075 +/// literally. Used both by the `glob` tool (against relative paths) and by
1076 +/// `search_files`' `file_glob` filter (against bare file names).
1077 +fn glob_to_regex(glob: &str) -> String {
1078 + let chars: Vec<char> = glob.chars().collect();
1079 + let mut re = String::from("^");
1080 + let mut brace_depth = 0usize;
1081 + let mut i = 0;
1082 +
1083 + while i < chars.len() {
1084 + let c = chars[i];
1085 + match c {
1086 + '*' => {
1087 + if i + 1 < chars.len() && chars[i + 1] == '*' {
1088 + i += 1; // consume the second '*'
1089 + if i + 1 < chars.len() && chars[i + 1] == '/' {
1090 + // `**/` matches zero or more leading directories.
1091 + re.push_str("(?:.*/)?");
1092 + i += 1; // consume the '/'
1093 + } else {
1094 + re.push_str(".*");
1095 + }
1096 + } else {
1097 + re.push_str("[^/]*");
1098 + }
1099 + }
1100 + '?' => re.push_str("[^/]"),
1101 + '{' => {
1102 + brace_depth += 1;
1103 + re.push_str("(?:");
1104 + }
1105 + '}' if brace_depth > 0 => {
1106 + brace_depth -= 1;
1107 + re.push(')');
1108 + }
1109 + ',' if brace_depth > 0 => re.push('|'),
1110 + // Escape regex metacharacters so they match literally. (`{` is always
1111 + // consumed by the brace arm above; an unmatched `}` lands here.)
1112 + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1113 + re.push('\\');
1114 + re.push(c);
1115 + }
1116 + other => re.push(other),
1117 + }
1118 + i += 1;
1119 + }
1120 +
1121 + re.push('$');
1122 + re
1123 +}
1124 +
1125 +fn exec_glob(arguments: &str) -> String {
1126 + let args: Value = match serde_json::from_str(arguments) {
1127 + Ok(v) => v,
1128 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1129 + };
1130 +
1131 + let pattern = match args.get("pattern").and_then(Value::as_str) {
1132 + Some(p) => p,
1133 + None => return "Error: missing required parameter \"pattern\"".to_string(),
1134 + };
1135 +
1136 + let re = match Regex::new(&glob_to_regex(pattern)) {
1137 + Ok(r) => r,
1138 + Err(err) => return format!("Error: invalid glob pattern: {err}"),
1139 + };
1140 +
1141 + let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1142 + let absolute_root = absolute_path(Path::new(root_str));
1143 + let absolute_root_str = absolute_root.display().to_string();
1144 +
1145 + if !absolute_root.exists() {
1146 + return format!("Error: path does not exist: {absolute_root_str}");
1147 + }
1148 + if !absolute_root.is_dir() {
1149 + return format!("Error: path is not a directory: {absolute_root_str}");
1150 + }
1151 +
1152 + let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1153 + glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1154 +
1155 + if found.is_empty() {
1156 + return format!("No files match glob: {pattern}");
1157 + }
1158 +
1159 + // Most-recently-modified first, like Claude Code's Glob.
1160 + found.sort_by(|a, b| b.0.cmp(&a.0));
1161 +
1162 + let total = found.len();
1163 + let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1164 + if total > SEARCH_RESULTS_HARD_CAP {
1165 + paths.truncate(SEARCH_RESULTS_HARD_CAP);
1166 + paths.push(format!(
1167 + "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1168 + ));
1169 + }
1170 +
1171 + paths.join("\n")
1172 +}
1173 +
1174 +fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1175 + if out.len() > SEARCH_RESULTS_HARD_CAP {
1176 + return;
1177 + }
1178 +
1179 + let entries = match fs::read_dir(dir) {
1180 + Ok(rd) => rd,
1181 + Err(_) => return,
1182 + };
1183 +
1184 + let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1185 + sorted.sort_by_key(|e| e.file_name());
1186 +
1187 + for entry in sorted {
1188 + if out.len() > SEARCH_RESULTS_HARD_CAP {
1189 + return;
1190 + }
1191 +
1192 + let path = entry.path();
1193 + let name = entry.file_name();
1194 + if name.to_string_lossy().starts_with('.') {
1195 + continue;
1196 + }
1197 +
1198 + if path.is_dir() {
1199 + glob_walk(root, &path, re, out);
1200 + } else if path.is_file() {
1201 + let relative = path
1202 + .strip_prefix(root)
1203 + .unwrap_or(&path)
1204 + .to_string_lossy()
1205 + .replace('\\', "/");
1206 + if re.is_match(&relative) {
1207 + let mtime = entry
1208 + .metadata()
1209 + .and_then(|m| m.modified())
1210 + .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1211 + out.push((mtime, absolute_path_string(&path)));
1212 + }
1213 + }
1214 + }
1215 +}
1216 +
1217 +// ── write_todos ──────────────────────────────────────────────────────────────
1218 +
1219 +/// Renders the model's task checklist back as the tool result so the surface
1220 +/// (TUI / ACP client) can show live progress. Pure presentation — the list is
1221 +/// owned by the model, not persisted here.
1222 +fn exec_write_todos(arguments: &str) -> String {
1223 + let args: Value = match serde_json::from_str(arguments) {
1224 + Ok(v) => v,
1225 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1226 + };
1227 +
1228 + let todos = match args.get("todos").and_then(Value::as_array) {
1229 + Some(t) if !t.is_empty() => t,
1230 + Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1231 + None => return "Error: missing required parameter \"todos\"".to_string(),
1232 + };
1233 +
1234 + let mut lines = Vec::with_capacity(todos.len());
1235 + let mut completed = 0usize;
1236 +
1237 + for (idx, todo) in todos.iter().enumerate() {
1238 + let content = match todo.get("content").and_then(Value::as_str) {
1239 + Some(c) => c.trim(),
1240 + None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1241 + };
1242 + let status = todo
1243 + .get("status")
1244 + .and_then(Value::as_str)
1245 + .unwrap_or("pending");
1246 +
1247 + let marker = match status {
1248 + "completed" => {
1249 + completed += 1;
1250 + "[x]"
1251 + }
1252 + "in_progress" => "[~]",
1253 + _ => "[ ]",
1254 + };
1255 + lines.push(format!("{marker} {content}"));
1256 + }
1257 +
1258 + format!(
1259 + "Task list updated ({completed}/{} done):\n{}",
1260 + todos.len(),
1261 + lines.join("\n")
1262 + )
1263 +}
1264 +
1265 +// ── remember ─────────────────────────────────────────────────────────────────
1266 +
1267 +/// Appends a durable note to the project's instruction file so it persists
1268 +/// across sessions (the always-on counterpart to a one-off chat message).
1269 +fn exec_remember(arguments: &str) -> String {
1270 + let args: Value = match serde_json::from_str(arguments) {
1271 + Ok(v) => v,
1272 + Err(err) => return format!("Error: failed to parse arguments: {err}"),
1273 + };
1274 +
1275 + let note = match args.get("note").and_then(Value::as_str) {
1276 + Some(n) if !n.trim().is_empty() => n.trim(),
1277 + Some(_) => return "Error: \"note\" must not be empty".to_string(),
1278 + None => return "Error: missing required parameter \"note\"".to_string(),
1279 + };
1280 +
1281 + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1282 + remember_at(&cwd, note)
1283 +}
1284 +
1285 +/// Core of `remember`, parameterized on the working directory so it can be
1286 +/// tested without mutating the process-global current directory.
1287 +fn remember_at(cwd: &Path, note: &str) -> String {
1288 + let target = crate::instructions::memory_file(cwd);
1289 + let target_str = target.display().to_string();
1290 +
1291 + let existed = target.exists();
1292 + let mut body = if existed {
1293 + match fs::read_to_string(&target) {
1294 + Ok(c) => c,
1295 + Err(err) => return format!("Error: could not read {target_str}: {err}"),
1296 + }
1297 + } else {
1298 + if let Some(parent) = target.parent()
1299 + && !parent.as_os_str().is_empty()
1300 + && !parent.exists()
1301 + && let Err(err) = fs::create_dir_all(parent)
1302 + {
1303 + return format!("Error: could not create parent directories: {err}");
1304 + }
1305 + String::new()
1306 + };
1307 +
1308 + // Keep remembered notes grouped under one heading so the file stays tidy.
1309 + const HEADING: &str = "## Remembered notes";
1310 + if !body.contains(HEADING) {
1311 + if !body.is_empty() && !body.ends_with('\n') {
1312 + body.push('\n');
1313 + }
1314 + if !body.is_empty() {
1315 + body.push('\n');
1316 + }
1317 + body.push_str(HEADING);
1318 + body.push('\n');
1319 + }
1320 + if !body.ends_with('\n') {
1321 + body.push('\n');
1322 + }
1323 + body.push_str("- ");
1324 + body.push_str(note);
1325 + body.push('\n');
1326 +
1327 + match fs::write(&target, &body) {
1328 + Ok(()) => {
1329 + let verb = if existed { "Appended to" } else { "Created" };
1330 + format!("{verb} {target_str}: remembered \"{note}\"")
1331 + }
1332 + Err(err) => format!("Error: could not write {target_str}: {err}"),
1333 + }
1334 +}
1335 +
1336 // ── delete_file ──────────────────────────────────────────────────────────────
1337
1338 /// only removes files or *empty* directories — no recursive deletes.
@@ -1025,7 +1593,7 @@ mod tests {
1593 #[test]
1594 fn test_all_tools_count() {
1595 let tools = all_tools();
1028 - assert_eq!(tools.len(), 9);
1596 + assert_eq!(tools.len(), 13);
1597 assert_eq!(tools[0].name, "read_file");
1598 assert_eq!(tools[1].name, "create_directory");
1599 assert_eq!(tools[2].name, "list_directory");
@@ -1035,6 +1603,186 @@ mod tests {
1603 assert_eq!(tools[6].name, "edit_file");
1604 assert_eq!(tools[7].name, "delete_file");
1605 assert_eq!(tools[8].name, "run_command");
1606 + assert_eq!(tools[9].name, "multi_edit");
1607 + assert_eq!(tools[10].name, "glob");
1608 + assert_eq!(tools[11].name, "write_todos");
1609 + assert_eq!(tools[12].name, "remember");
1610 + }
1611 +
1612 + #[test]
1613 + fn test_edit_file_replace_all() {
1614 + let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1615 + let _ = fs::remove_dir_all(&dir);
1616 + fs::create_dir_all(&dir).unwrap();
1617 + let file = dir.join("f.txt");
1618 + fs::write(&file, "foo foo foo").unwrap();
1619 +
1620 + // Without replace_all an ambiguous match is rejected.
1621 + let args =
1622 + serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1623 + let result = exec_edit_file(&args);
1624 + assert!(result.contains("appears 3 times"), "{result}");
1625 +
1626 + // With replace_all every occurrence is changed.
1627 + let args = serde_json::json!({
1628 + "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1629 + })
1630 + .to_string();
1631 + let result = exec_edit_file(&args);
1632 + assert!(result.starts_with("Edited file:"), "{result}");
1633 + assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1634 +
1635 + let _ = fs::remove_dir_all(&dir);
1636 + }
1637 +
1638 + #[test]
1639 + fn test_edit_file_whitespace_hint() {
1640 + let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1641 + let _ = fs::remove_dir_all(&dir);
1642 + fs::create_dir_all(&dir).unwrap();
1643 + let file = dir.join("f.txt");
1644 + fs::write(&file, "line one\n indented\nline three\n").unwrap();
1645 +
1646 + // old_text has more indentation than the file, so it isn't a substring,
1647 + // but its trimmed content still locates the intended line.
1648 + let args = serde_json::json!({
1649 + "path": &file, "old_text": " indented", "new_text": "x"
1650 + })
1651 + .to_string();
1652 + let result = exec_edit_file(&args);
1653 + assert!(result.contains("line 2"), "{result}");
1654 + assert!(result.contains("whitespace"), "{result}");
1655 +
1656 + let _ = fs::remove_dir_all(&dir);
1657 + }
1658 +
1659 + #[test]
1660 + fn test_multi_edit_atomic_on_failure() {
1661 + let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1662 + let _ = fs::remove_dir_all(&dir);
1663 + fs::create_dir_all(&dir).unwrap();
1664 + let file = dir.join("f.txt");
1665 + fs::write(&file, "alpha beta gamma").unwrap();
1666 +
1667 + // Second edit can't match -> nothing should be written.
1668 + let args = serde_json::json!({
1669 + "path": &file,
1670 + "edits": [
1671 + { "old_text": "alpha", "new_text": "ALPHA" },
1672 + { "old_text": "nope", "new_text": "x" }
1673 + ]
1674 + })
1675 + .to_string();
1676 + let result = exec_multi_edit(&args);
1677 + assert!(result.contains("edit #2 failed"), "{result}");
1678 + assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1679 +
1680 + // All-matching batch applies in sequence.
1681 + let args = serde_json::json!({
1682 + "path": &file,
1683 + "edits": [
1684 + { "old_text": "alpha", "new_text": "ALPHA" },
1685 + { "old_text": "gamma", "new_text": "GAMMA" }
1686 + ]
1687 + })
1688 + .to_string();
1689 + let result = exec_multi_edit(&args);
1690 + assert!(result.contains("Applied 2 edits"), "{result}");
1691 + assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1692 +
1693 + let _ = fs::remove_dir_all(&dir);
1694 + }
1695 +
1696 + #[test]
1697 + fn test_glob_to_regex() {
1698 + let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1699 + assert!(re.is_match("src/tools.rs"));
1700 + assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1701 + assert!(!re.is_match("src/tools.txt"));
1702 +
1703 + let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1704 + assert!(re.is_match("app.ts"));
1705 + assert!(re.is_match("app.tsx"));
1706 + assert!(!re.is_match("app.js"));
1707 + }
1708 +
1709 + #[test]
1710 + fn test_glob_tool_success() {
1711 + let dir = std::env::temp_dir().join("sigit_test_glob");
1712 + let _ = fs::remove_dir_all(&dir);
1713 + fs::create_dir_all(dir.join("src")).unwrap();
1714 + fs::write(dir.join("Cargo.toml"), "").unwrap();
1715 + fs::write(dir.join("src/main.rs"), "").unwrap();
1716 + fs::write(dir.join("src/lib.rs"), "").unwrap();
1717 +
1718 + let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1719 + let result = exec_glob(&args);
1720 + assert!(result.contains("main.rs"), "{result}");
1721 + assert!(result.contains("lib.rs"), "{result}");
1722 + assert!(!result.contains("Cargo.toml"), "{result}");
1723 +
1724 + let _ = fs::remove_dir_all(&dir);
1725 + }
1726 +
1727 + #[test]
1728 + fn test_search_files_file_glob_filter() {
1729 + let dir = std::env::temp_dir().join("sigit_test_search_glob");
1730 + let _ = fs::remove_dir_all(&dir);
1731 + fs::create_dir_all(&dir).unwrap();
1732 + fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1733 + fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1734 +
1735 + let args = serde_json::json!({
1736 + "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1737 + })
1738 + .to_string();
1739 + let result = exec_search_files(&args);
1740 + assert!(result.contains("code.rs"), "{result}");
1741 + assert!(!result.contains("notes.txt"), "{result}");
1742 +
1743 + let _ = fs::remove_dir_all(&dir);
1744 + }
1745 +
1746 + #[test]
1747 + fn test_write_todos_renders_checklist() {
1748 + let args = serde_json::json!({
1749 + "todos": [
1750 + { "content": "Read code", "status": "completed" },
1751 + { "content": "Make change", "status": "in_progress" },
1752 + { "content": "Run tests", "status": "pending" }
1753 + ]
1754 + })
1755 + .to_string();
1756 + let result = exec_write_todos(&args);
1757 + assert!(result.contains("1/3 done"), "{result}");
1758 + assert!(result.contains("[x] Read code"), "{result}");
1759 + assert!(result.contains("[~] Make change"), "{result}");
1760 + assert!(result.contains("[ ] Run tests"), "{result}");
1761 + }
1762 +
1763 + #[test]
1764 + fn test_remember_appends_to_instruction_file() {
1765 + let dir = std::env::temp_dir().join("sigit_test_remember");
1766 + let _ = fs::remove_dir_all(&dir);
1767 + fs::create_dir_all(dir.join(".git")).unwrap();
1768 + let claude_md = dir.join("CLAUDE.md");
1769 + fs::write(&claude_md, "# Project\n").unwrap();
1770 +
1771 + let target = crate::instructions::memory_file(&dir);
1772 + // Should pick the existing CLAUDE.md at the repo root.
1773 + assert_eq!(
1774 + target.canonicalize().unwrap(),
1775 + claude_md.canonicalize().unwrap()
1776 + );
1777 +
1778 + let result = remember_at(&dir, "remembered text");
1779 + assert!(result.contains("remembered"), "{result}");
1780 +
1781 + let updated = fs::read_to_string(&claude_md).unwrap();
1782 + assert!(updated.contains("## Remembered notes"), "{updated}");
1783 + assert!(updated.contains("- remembered text"), "{updated}");
1784 +
1785 + let _ = fs::remove_dir_all(&dir);
1786 }
1787
1788 #[test]