A monorepo management tool for the agentic ages

Overhaul unpac-claude for autonomous code analysis mode

Major enhancements to the Claude agent:

- Add autonomous mode (-a) that continuously analyzes all projects
- Add workspace path argument (-w) for specifying workspace location
- Add periodic sync (unpac status/push) with configurable interval
- Add rate limit handling with exponential backoff (5s base, 5min max)

New file operation tools:
- read_file: Read source code and config files
- write_file: Write/update files (code, STATUS.md)
- list_directory: Explore directory structure
- glob_files: Find files by pattern (*.ml, etc)

New shell execution tool:
- run_shell: Execute commands like dune build/test

New workspace sync tools:
- unpac_status_sync: Update README.md and sync state
- unpac_push: Push all branches to remote
- git_commit: Commit changes with message

Autonomous mode workflow:
1. List all projects in workspace
2. For each project, read STATUS.md and source files
3. Analyze code quality (Stdlib combinators, higher-order functions)
4. Check test coverage and flag missing tests
5. Update STATUS.md with findings and shortcomings
6. Make focused improvements and commit changes
7. Periodically sync with remote

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+932 -108
+64 -13
bin/unpac-claude/main.ml
··· 8 8 Logs.set_level (Some level); 9 9 Logs.set_reporter (Logs_fmt.reporter ()) 10 10 11 - let run_agent model max_turns verbose prompt_opt = 11 + let run_agent model max_turns verbose autonomous sync_interval workspace_path prompt_opt = 12 12 setup_logging verbose; 13 13 Eio_main.run @@ fun env -> 14 14 let model = match model with ··· 21 21 model; 22 22 max_turns; 23 23 verbose; 24 + autonomous; 25 + sync_interval; 24 26 } in 25 - Unpac_claude.Agent.run ~env ~config ~initial_prompt:prompt_opt 27 + Unpac_claude.Agent.run ~env ~config ~initial_prompt:prompt_opt ?workspace_path () 26 28 27 29 (* CLI *) 28 30 let model_arg = ··· 37 39 let doc = "Enable verbose logging." in 38 40 Arg.(value & flag & info ["v"; "verbose"] ~doc) 39 41 42 + let autonomous_arg = 43 + let doc = "Run in autonomous mode. The agent will continuously analyze and \ 44 + improve projects without waiting for user input. It will regularly \ 45 + run unpac status/push to sync with remote." in 46 + Arg.(value & flag & info ["a"; "autonomous"] ~doc) 47 + 48 + let sync_interval_arg = 49 + let doc = "In autonomous mode, run unpac status and push every N turns." in 50 + Arg.(value & opt int 10 & info ["sync-interval"] ~docv:"N" ~doc) 51 + 52 + let workspace_arg = 53 + let doc = "Path to the unpac workspace to analyze. If not specified, searches \ 54 + upward from current directory." in 55 + Arg.(value & opt (some string) None & info ["w"; "workspace"] ~docv:"PATH" ~doc) 56 + 40 57 let prompt_arg = 41 58 let doc = "Initial prompt to start the agent with (optional)." in 42 59 Arg.(value & pos 0 (some string) None & info [] ~docv:"PROMPT" ~doc) 43 60 44 61 let cmd = 45 - let doc = "Autonomous Claude agent for unpac workflows" in 62 + let doc = "Autonomous Claude agent for unpac workspace analysis and improvement" in 46 63 let man = [ 47 64 `S Manpage.s_description; 48 - `P "An autonomous Claude agent that understands unpac workflows and can \ 49 - explore and code in a loop until interrupted."; 50 - `P "The agent uses the claude.dev library to communicate with Claude Code CLI, \ 51 - and has access to unpac tools for managing vendored dependencies."; 65 + `P "An autonomous Claude agent that analyzes and improves OCaml projects \ 66 + in an unpac workspace. It can run interactively or autonomously."; 67 + `S "MODES"; 68 + `I ("Interactive (default)", "Responds to user prompts in a REPL-style \ 69 + conversation. Use for directed exploration \ 70 + and specific tasks."); 71 + `I ("Autonomous (-a)", "Continuously analyzes all projects, updates STATUS.md \ 72 + files, refactors code, and syncs with remote. Runs \ 73 + until max-turns or manual interruption."); 74 + `S "AUTONOMOUS ANALYSIS"; 75 + `P "In autonomous mode, the agent will:"; 76 + `I ("1. Project Analysis", "Read STATUS.md, scan source files, check tests"); 77 + `I ("2. Documentation", "Update STATUS.md with current state and TODOs"); 78 + `I ("3. Code Quality", "Refactor using OCaml Stdlib combinators, remove \ 79 + repetitive code, use higher-order functions"); 80 + `I ("4. Test Coverage", "Identify missing tests, flag untested code"); 81 + `I ("5. Sync", "Periodically run 'unpac status' and 'unpac push origin'"); 82 + `S "RATE LIMITING"; 83 + `P "The agent automatically handles API rate limits with exponential backoff. \ 84 + If rate limited, it will pause and retry automatically."; 52 85 `S Manpage.s_examples; 53 86 `P "Start interactive mode:"; 54 87 `Pre " unpac-claude"; 55 - `P "Start with an initial goal:"; 56 - `Pre " unpac-claude \"Add the ocaml-yaml repo as a dependency\""; 57 - `P "Use a specific model:"; 58 - `Pre " unpac-claude -m opus \"Review the codebase structure\""; 88 + `P "Start autonomous analysis of a workspace:"; 89 + `Pre " unpac-claude -a -w /path/to/workspace"; 90 + `P "Autonomous with custom sync interval:"; 91 + `Pre " unpac-claude -a --sync-interval 5 -w /path/to/workspace"; 92 + `P "Autonomous with turn limit:"; 93 + `Pre " unpac-claude -a --max-turns 50 -w /path/to/workspace"; 94 + `P "Interactive with initial prompt:"; 95 + `Pre " unpac-claude \"Analyze the brotli project and update its STATUS.md\""; 96 + `P "Use Opus model for more complex analysis:"; 97 + `Pre " unpac-claude -m opus -a -w /path/to/workspace"; 59 98 `S "AVAILABLE TOOLS"; 60 99 `P "The agent has access to these tools:"; 61 100 `I ("unpac_status", "Get workspace overview"); 101 + `I ("unpac_status_sync", "Run unpac status to update README.md"); 102 + `I ("unpac_push", "Push all branches to remote"); 62 103 `I ("unpac_git_list", "List vendored git repos"); 63 104 `I ("unpac_git_add", "Add a git repository"); 64 105 `I ("unpac_git_info", "Get repo details"); 65 106 `I ("unpac_git_diff", "Show local changes"); 66 107 `I ("unpac_opam_list", "List vendored opam packages"); 67 108 `I ("unpac_project_list", "List projects"); 109 + `I ("read_file", "Read source code and config files"); 110 + `I ("write_file", "Update code or STATUS.md"); 111 + `I ("list_directory", "Explore directory structure"); 112 + `I ("glob_files", "Find files by pattern"); 113 + `I ("run_shell", "Run dune build/test commands"); 114 + `I ("git_commit", "Commit changes"); 115 + `S "EXIT STATUS"; 116 + `P "The agent exits with 0 on normal completion (max-turns reached or \ 117 + user quit). It can be interrupted with Ctrl+C."; 68 118 ] in 69 - let info = Cmd.info "unpac-claude" ~version:"0.1.0" ~doc ~man in 70 - Cmd.v info Term.(const run_agent $ model_arg $ max_turns_arg $ verbose_arg $ prompt_arg) 119 + let info = Cmd.info "unpac-claude" ~version:"0.2.0" ~doc ~man in 120 + Cmd.v info Term.(const run_agent $ model_arg $ max_turns_arg $ verbose_arg $ 121 + autonomous_arg $ sync_interval_arg $ workspace_arg $ prompt_arg) 71 122 72 123 let () = exit (Cmd.eval cmd)
+237 -76
lib/claude/agent.ml
··· 1 1 (** Autonomous Claude agent for unpac workflows. 2 2 3 - This is a simplified implementation that uses the Claude CLI directly 4 - via the claude library's Client interface. *) 3 + This implementation supports two modes: 4 + 1. Interactive - REPL-style conversation with user input 5 + 2. Autonomous - Continuous analysis and improvement loop *) 5 6 6 7 let src = Logs.Src.create "unpac.claude.agent" ~doc:"Claude agent" 7 8 module Log = (val Logs.src_log src : Logs.LOG) ··· 10 11 model : [ `Sonnet | `Opus | `Haiku ]; 11 12 max_turns : int option; 12 13 verbose : bool; 14 + autonomous : bool; 15 + sync_interval : int; (* turns between unpac status/push *) 13 16 } 14 17 15 18 let default_config = { 16 19 model = `Sonnet; 17 20 max_turns = None; 18 21 verbose = false; 22 + autonomous = false; 23 + sync_interval = 10; 24 + } 25 + 26 + (* Rate limit state *) 27 + type rate_limit_state = { 28 + mutable consecutive_errors : int; 29 + mutable backoff_until : float; 30 + } 31 + 32 + let create_rate_limit_state () = { 33 + consecutive_errors = 0; 34 + backoff_until = 0.0; 19 35 } 20 36 21 - (* Find unpac root from current directory *) 22 - let find_root fs = 23 - let rec search dir depth = 37 + (* Calculate backoff time with exponential increase *) 38 + let calculate_backoff state = 39 + let base_delay = 5.0 in (* 5 seconds base *) 40 + let max_delay = 300.0 in (* 5 minutes max *) 41 + let delay = min max_delay (base_delay *. (2.0 ** float_of_int state.consecutive_errors)) in 42 + delay 43 + 44 + (* Check if we should wait due to rate limiting *) 45 + let should_wait_for_rate_limit ~clock state = 46 + let now = Eio.Time.now clock in 47 + if now < state.backoff_until then begin 48 + let wait_time = state.backoff_until -. now in 49 + Log.info (fun m -> m "Rate limited, waiting %.1f seconds..." wait_time); 50 + Some wait_time 51 + end else 52 + None 53 + 54 + (* Record a rate limit error *) 55 + let record_rate_limit_error ~clock state = 56 + let now = Eio.Time.now clock in 57 + state.consecutive_errors <- state.consecutive_errors + 1; 58 + let delay = calculate_backoff state in 59 + state.backoff_until <- now +. delay; 60 + Log.warn (fun m -> m "Rate limit error #%d, backing off for %.1f seconds" 61 + state.consecutive_errors delay); 62 + delay 63 + 64 + (* Record successful request *) 65 + let record_success state = 66 + state.consecutive_errors <- 0 67 + 68 + (* Find unpac root from a given directory *) 69 + let find_root_from fs dir = 70 + let rec search path depth = 24 71 if depth > 10 then None 25 72 else begin 26 - let git_path = Eio.Path.(dir / "git") in 27 - let main_path = Eio.Path.(dir / "main") in 73 + let git_path = Eio.Path.(path / "git") in 74 + let main_path = Eio.Path.(path / "main") in 28 75 if Eio.Path.is_directory git_path && Eio.Path.is_directory main_path then 29 - Some dir 76 + Some path 30 77 else 31 - match Eio.Path.split dir with 78 + match Eio.Path.split path with 32 79 | Some (parent, _) -> search parent (depth + 1) 33 80 | None -> None 34 81 end 35 82 in 36 - search (Eio.Path.(fs / Sys.getcwd ())) 0 83 + search (Eio.Path.(fs / dir)) 0 84 + 85 + (* Find unpac root from current directory *) 86 + let find_root fs = 87 + find_root_from fs (Sys.getcwd ()) 37 88 38 89 (* Convert our model type to claude library model *) 39 90 let to_claude_model = function ··· 41 92 | `Opus -> `Opus_4 42 93 | `Haiku -> `Haiku_4 43 94 44 - let run ~env ~config ~initial_prompt = 95 + (* Helper for substring checking *) 96 + let string_contains ~sub s = 97 + let len_sub = String.length sub in 98 + let len_s = String.length s in 99 + if len_sub > len_s then false 100 + else begin 101 + let rec check i = 102 + if i > len_s - len_sub then false 103 + else if String.sub s i len_sub = sub then true 104 + else check (i + 1) 105 + in 106 + check 0 107 + end 108 + 109 + let is_rate_limit_error msg = 110 + let s = String.lowercase_ascii msg in 111 + string_contains ~sub:"rate" s || 112 + string_contains ~sub:"429" s || 113 + string_contains ~sub:"limit" s || 114 + string_contains ~sub:"overloaded" s || 115 + string_contains ~sub:"quota" s 116 + 117 + (* Run the agent *) 118 + let run ~env ~config ~initial_prompt ?workspace_path () = 45 119 let fs = Eio.Stdenv.fs env in 46 120 let proc_mgr = Eio.Stdenv.process_mgr env in 47 121 let clock = Eio.Stdenv.clock env in 48 122 49 123 (* Find unpac root *) 50 - let root = match find_root fs with 51 - | Some r -> r 124 + let root = match workspace_path with 125 + | Some path -> 126 + (match find_root_from fs path with 127 + | Some r -> r 128 + | None -> 129 + Format.eprintf "Error: '%s' is not an unpac workspace.@." path; 130 + exit 1) 52 131 | None -> 53 - Format.eprintf "Error: Not in an unpac workspace.@."; 54 - Format.eprintf "Run 'unpac init' to create one.@."; 55 - exit 1 132 + (match find_root fs with 133 + | Some r -> r 134 + | None -> 135 + Format.eprintf "Error: Not in an unpac workspace.@."; 136 + Format.eprintf "Run 'unpac init' to create one or specify --workspace.@."; 137 + exit 1) 56 138 in 57 139 58 140 Log.info (fun m -> m "Starting agent in workspace: %s" (snd root)); 141 + if config.autonomous then 142 + Log.info (fun m -> m "Running in AUTONOMOUS mode"); 59 143 60 144 (* Generate system prompt *) 61 - let system_prompt = Prompt.generate ~proc_mgr ~root in 145 + let system_prompt = Prompt.generate ~proc_mgr ~root ~autonomous:config.autonomous in 62 146 Log.debug (fun m -> m "System prompt length: %d" (String.length system_prompt)); 63 147 64 148 (* Build Claude options with our tools *) ··· 71 155 |> Claude.Options.with_permission_mode Claude.Permissions.Mode.Bypass_permissions 72 156 in 73 157 158 + (* Rate limit state *) 159 + let rate_state = create_rate_limit_state () in 160 + 74 161 (* Main loop *) 75 - let rec loop turn_count prompt = 162 + let rec loop turn_count last_sync prompt = 76 163 (* Check turn limit *) 77 164 match config.max_turns with 78 165 | Some max when turn_count >= max -> 79 166 Format.printf "@.Reached maximum turns (%d). Exiting.@." max 80 167 | _ -> 168 + (* Check rate limit *) 169 + (match should_wait_for_rate_limit ~clock rate_state with 170 + | Some wait_time -> 171 + Format.printf "Waiting %.0f seconds for rate limit...@." wait_time; 172 + Eio.Time.sleep clock wait_time 173 + | None -> ()); 174 + 175 + (* Check if we should sync *) 176 + let should_sync = config.autonomous && 177 + turn_count > 0 && 178 + turn_count - last_sync >= config.sync_interval in 179 + let (prompt, last_sync) = 180 + if should_sync then begin 181 + Log.info (fun m -> m "Periodic sync: running unpac status and push"); 182 + ("First run unpac_status_sync to update the workspace status, then run \ 183 + unpac_push with remote='origin' to sync changes. After that, continue \ 184 + analyzing and improving the codebase.", turn_count) 185 + end else 186 + (prompt, last_sync) 187 + in 188 + 81 189 (* Create client and send message *) 82 - Eio.Switch.run @@ fun sw -> 83 - let client = Claude.Client.create ~sw ~process_mgr:proc_mgr ~clock ~options () in 190 + let continue = ref true in 191 + let error_occurred = ref false in 192 + begin 193 + try 194 + Eio.Switch.run @@ fun sw -> 195 + let client = Claude.Client.create ~sw ~process_mgr:proc_mgr ~clock ~options () in 196 + 197 + (* Create response handler that handles tool calls *) 198 + let handler = object 199 + inherit Claude.Handler.default 84 200 85 - (* Create response handler that handles tool calls *) 86 - let handler = object 87 - inherit Claude.Handler.default 201 + method! on_text text = 202 + print_string (Claude.Response.Text.content text); 203 + flush stdout 88 204 89 - method! on_text text = 90 - print_string (Claude.Response.Text.content text); 91 - flush stdout 205 + method! on_tool_use tool = 206 + let name = Claude.Response.Tool_use.name tool in 207 + let id = Claude.Response.Tool_use.id tool in 208 + let input = Claude.Response.Tool_use.input tool in 92 209 93 - method! on_tool_use tool = 94 - let name = Claude.Response.Tool_use.name tool in 95 - let id = Claude.Response.Tool_use.id tool in 96 - let input = Claude.Response.Tool_use.input tool in 210 + if config.verbose then 211 + Log.info (fun m -> m "Tool call: %s" name); 97 212 98 - if config.verbose then 99 - Log.info (fun m -> m "Tool call: %s" name); 213 + (* Convert Tool_input.t to Jsont.json *) 214 + let json_input = Claude.Tool_input.to_json input in 100 215 101 - (* Convert Tool_input.t to Jsont.json *) 102 - let json_input = Claude.Tool_input.to_json input in 216 + (* Execute the tool *) 217 + let result = Tools.execute ~proc_mgr ~fs ~root ~tool_name:name ~input:json_input in 218 + let (is_error, content) = match result with 219 + | Tools.Success s -> (false, s) 220 + | Tools.Error e -> (true, e) 221 + in 103 222 104 - (* Execute the tool *) 105 - let result = Tools.execute ~proc_mgr ~fs ~root ~tool_name:name ~input:json_input in 106 - let (is_error, content) = match result with 107 - | Tools.Success s -> (false, s) 108 - | Tools.Error e -> (true, e) 109 - in 223 + if config.verbose then 224 + Log.info (fun m -> m "Tool result: %s..." 225 + (String.sub content 0 (min 100 (String.length content)))); 110 226 111 - if config.verbose then 112 - Log.info (fun m -> m "Tool result: %s..." (String.sub content 0 (min 100 (String.length content)))); 227 + (* Send tool result back to Claude *) 228 + let meta = Jsont.Meta.none in 229 + let content_json = Jsont.String (content, meta) in 230 + Claude.Client.respond_to_tool client ~tool_use_id:id ~content:content_json ~is_error () 113 231 114 - (* Send tool result back to Claude *) 115 - let meta = Jsont.Meta.none in 116 - let content_json = Jsont.String (content, meta) in 117 - Claude.Client.respond_to_tool client ~tool_use_id:id ~content:content_json ~is_error () 232 + method! on_complete result = 233 + print_newline (); 234 + record_success rate_state; 235 + if config.verbose then begin 236 + let cost = Claude.Response.Complete.total_cost_usd result in 237 + match cost with 238 + | Some c -> Log.info (fun m -> m "Turn complete, cost: $%.4f" c) 239 + | None -> Log.info (fun m -> m "Turn complete") 240 + end 118 241 119 - method! on_complete result = 120 - print_newline (); 121 - if config.verbose then begin 122 - let cost = Claude.Response.Complete.total_cost_usd result in 123 - match cost with 124 - | Some c -> Log.info (fun m -> m "Turn complete, cost: $%.4f" c) 125 - | None -> Log.info (fun m -> m "Turn complete") 126 - end 127 - end in 242 + method! on_error err = 243 + let msg = Claude.Response.Error.message err in 244 + Log.err (fun m -> m "Claude error: %s" msg); 245 + if is_rate_limit_error msg then begin 246 + let delay = record_rate_limit_error ~clock rate_state in 247 + Format.eprintf "Rate limit hit, will retry in %.0f seconds@." delay 248 + end; 249 + error_occurred := true 250 + end in 128 251 129 - (* Send the prompt and run handler *) 130 - Claude.Client.query client prompt; 131 - Claude.Client.run client ~handler; 252 + (* Send the prompt and run handler *) 253 + Claude.Client.query client prompt; 254 + Claude.Client.run client ~handler 255 + with exn -> 256 + let msg = Printexc.to_string exn in 257 + Log.err (fun m -> m "Exception: %s" msg); 258 + if is_rate_limit_error msg then begin 259 + let delay = record_rate_limit_error ~clock rate_state in 260 + Format.eprintf "Rate limit exception, will retry in %.0f seconds@." delay 261 + end; 262 + error_occurred := true 263 + end; 132 264 133 - (* Prompt for next input *) 134 - print_newline (); 135 - print_string "> "; 136 - flush stdout; 265 + (* Handle next iteration *) 266 + if !error_occurred && config.autonomous then begin 267 + (* In autonomous mode, retry after error with backoff *) 268 + Log.info (fun m -> m "Error occurred, will retry..."); 269 + loop turn_count last_sync "Continue with your analysis. If you encountered an error, try a different approach." 270 + end else if config.autonomous then begin 271 + (* Autonomous mode: continue automatically *) 272 + print_newline (); 273 + loop (turn_count + 1) last_sync 274 + "Continue analyzing and improving the projects. Remember to:\n\ 275 + 1. Update STATUS.md with your findings\n\ 276 + 2. Make small, focused improvements\n\ 277 + 3. Commit changes with clear messages\n\ 278 + 4. Run dune build to verify changes work\n\ 279 + If you've completed analysis of all projects, focus on the highest priority improvements." 280 + end else begin 281 + (* Interactive mode: prompt for next input *) 282 + print_newline (); 283 + print_string "> "; 284 + flush stdout; 137 285 138 - (* Read next user input *) 139 - let next_prompt = 140 - try Some (input_line stdin) 141 - with End_of_file -> None 142 - in 286 + (* Read next user input *) 287 + let next_prompt = 288 + try Some (input_line stdin) 289 + with End_of_file -> continue := false; None 290 + in 143 291 144 - match next_prompt with 145 - | None -> () 146 - | Some "" -> loop (turn_count + 1) "Continue with the current task." 147 - | Some "exit" | Some "quit" -> Format.printf "Goodbye!@." 148 - | Some p -> loop (turn_count + 1) p 292 + match next_prompt with 293 + | None -> () 294 + | Some "" -> loop (turn_count + 1) last_sync "Continue with the current task." 295 + | Some "exit" | Some "quit" -> Format.printf "Goodbye!@." 296 + | Some p -> loop (turn_count + 1) last_sync p 297 + end 149 298 in 150 299 151 300 (* Start the loop *) 152 301 let start_prompt = match initial_prompt with 153 302 | Some p -> p 154 303 | None -> 155 - print_string "unpac-claude> What would you like me to help with?\n> "; 156 - flush stdout; 157 - input_line stdin 304 + if config.autonomous then 305 + "Start your autonomous analysis of the workspace. List all projects, \ 306 + then systematically analyze each one. For each project:\n\ 307 + 1. Check if STATUS.md exists and read it\n\ 308 + 2. List all source files\n\ 309 + 3. Read key files to understand the code\n\ 310 + 4. Check for tests\n\ 311 + 5. Update STATUS.md with your findings\n\ 312 + 6. Identify opportunities for code improvement\n\ 313 + Begin now." 314 + else begin 315 + print_string "unpac-claude> What would you like me to help with?\n> "; 316 + flush stdout; 317 + input_line stdin 318 + end 158 319 in 159 320 160 321 print_newline (); 161 - loop 0 start_prompt 322 + loop 0 0 start_prompt
+8 -1
lib/claude/agent.mli
··· 11 11 model : [ `Sonnet | `Opus | `Haiku ]; 12 12 max_turns : int option; (** None = unlimited *) 13 13 verbose : bool; 14 + autonomous : bool; (** Autonomous mode - continuous analysis *) 15 + sync_interval : int; (** Turns between unpac status/push in autonomous mode *) 14 16 } 15 17 16 18 val default_config : config ··· 21 23 env:Eio_unix.Stdenv.base -> 22 24 config:config -> 23 25 initial_prompt:string option -> 26 + ?workspace_path:string -> 27 + unit -> 24 28 unit 25 - (** [run ~env ~config ~initial_prompt] starts the agent loop. 29 + (** [run ~env ~config ~initial_prompt ?workspace_path ()] starts the agent loop. 26 30 27 31 If [initial_prompt] is provided, the agent starts working on that goal. 28 32 Otherwise, it enters an interactive mode waiting for user input. 33 + 34 + If [workspace_path] is provided, the agent starts in that workspace. 35 + Otherwise, it searches upward from the current directory. 29 36 30 37 The agent runs until: 31 38 - User presses Ctrl+C
+161 -5
lib/claude/prompt.ml
··· 1 - (** Dynamic system prompt generation for Claude agent. *) 1 + (** Dynamic system prompt generation for autonomous Claude agent. *) 2 2 3 3 let src = Logs.Src.create "unpac.claude.prompt" ~doc:"Prompt generation" 4 4 module Log = (val Logs.src_log src : Logs.LOG) 5 5 6 - let base_prompt = {|You are an autonomous coding agent running in an unpac workspace. 6 + let autonomous_base_prompt = {|You are an autonomous code maintenance agent for OCaml projects in an unpac workspace. 7 + 8 + ## Your Mission 9 + 10 + You continuously analyze and improve the codebase by: 11 + 12 + 1. **Analyzing Projects**: Review each project's code, STATUS.md, and tests 13 + 2. **Completing Features**: Implement incomplete functionality marked in STATUS.md 14 + 3. **Recording Status**: Faithfully update STATUS.md with current state and shortcomings 15 + 4. **Code Quality**: Refactor using OCaml Stdlib combinators and higher-order functions 16 + 5. **Test Coverage**: Identify missing tests and add them where needed 17 + 6. **Syncing Changes**: Regularly run unpac_status_sync and unpac_push to keep remote updated 18 + 19 + ## OCaml Code Quality Guidelines 20 + 21 + When reviewing and improving OCaml code, look for: 22 + 23 + ### Replace Imperative Patterns with Functional Idioms 24 + - Replace `for` loops with `List.iter`, `List.map`, `List.fold_left` 25 + - Replace mutable refs with functional accumulation 26 + - Use `Option.map`, `Option.bind`, `Result.map`, `Result.bind` instead of pattern matching 27 + - Use `|>` pipeline operator for cleaner composition 28 + 29 + ### Stdlib Combinators to Prefer 30 + ```ocaml 31 + (* Instead of manual recursion, use: *) 32 + List.filter_map (* filter and map in one pass *) 33 + List.concat_map (* map then flatten *) 34 + List.find_opt (* safe find *) 35 + List.assoc_opt (* safe association lookup *) 36 + Option.value (* provide default *) 37 + Option.join (* flatten option option *) 38 + String.concat (* join strings *) 39 + String.split_on_char 40 + ``` 41 + 42 + ### Common Refactoring Patterns 43 + ```ocaml 44 + (* BEFORE: *) 45 + let result = ref [] in 46 + List.iter (fun x -> 47 + if pred x then result := transform x :: !result 48 + ) items; 49 + List.rev !result 50 + 51 + (* AFTER: *) 52 + items |> List.filter_map (fun x -> 53 + if pred x then Some (transform x) else None 54 + ) 55 + 56 + (* BEFORE: *) 57 + match opt with 58 + | Some x -> Some (f x) 59 + | None -> None 60 + 61 + (* AFTER: *) 62 + Option.map f opt 63 + 64 + (* BEFORE: *) 65 + match foo () with 66 + | Ok x -> bar x 67 + | Error _ as e -> e 68 + 69 + (* AFTER: *) 70 + Result.bind (foo ()) bar 71 + ``` 72 + 73 + ## STATUS.md Format 74 + 75 + Each project should have a STATUS.md with: 76 + 77 + ```markdown 78 + # Project Name 79 + 80 + **Status**: [STUB | IN_PROGRESS | COMPLETE | NEEDS_REVIEW] 81 + 82 + ## Overview 83 + Brief description of what this project does. 84 + 85 + ## Current State 86 + - What is implemented 87 + - What works 88 + 89 + ## TODO 90 + - [ ] Task 1 91 + - [ ] Task 2 92 + - [x] Completed task 93 + 94 + ## Known Issues 95 + - Issue 1 96 + - Issue 2 97 + 98 + ## Test Coverage 99 + - What is tested 100 + - What needs tests 101 + 102 + ## Dependencies 103 + - Required packages 104 + ``` 105 + 106 + ## Workflow 107 + 108 + 1. **Start**: List all projects with unpac_project_list 109 + 2. **For each project**: 110 + - Read STATUS.md if it exists 111 + - Glob all *.ml files 112 + - Read key source files 113 + - Analyze code quality 114 + - Check for tests (look for test/ or *_test.ml files) 115 + - Update STATUS.md with findings 116 + - Make small, focused improvements 117 + - Commit changes with clear messages 118 + 3. **Periodically**: Run unpac_status_sync and unpac_push to sync 119 + 120 + ## Rate Limit Handling 121 + 122 + If you encounter rate limit errors: 123 + - Wait the indicated time before retrying 124 + - The system will handle backoff automatically 125 + - Focus on one project at a time to avoid rapid API calls 126 + 127 + ## Important Rules 128 + 129 + 1. **Small Changes**: Make incremental improvements, not sweeping rewrites 130 + 2. **Commit Often**: Commit after each logical change 131 + 3. **Document**: Always update STATUS.md to reflect current state 132 + 4. **Test First**: Run dune build before committing code changes 133 + 5. **Push Regularly**: Keep remote in sync with local changes 134 + 6. **Be Honest**: Record actual shortcomings, don't hide problems 135 + 136 + ## Available Tools 137 + 138 + You have access to these tools: 139 + - **unpac_status**: Get workspace overview 140 + - **unpac_status_sync**: Update README.md and sync state 141 + - **unpac_push**: Push all branches to remote 142 + - **unpac_project_list**: List projects 143 + - **unpac_opam_list**: List vendored packages 144 + - **unpac_git_list**: List vendored git repos 145 + - **read_file**: Read source code and config files 146 + - **write_file**: Update code or STATUS.md 147 + - **list_directory**: Explore directory structure 148 + - **glob_files**: Find files by pattern 149 + - **run_shell**: Run dune build/test commands 150 + - **git_commit**: Commit changes 151 + 152 + Start by getting the workspace status and listing all projects. 153 + |} 154 + 155 + let interactive_base_prompt = {|You are an autonomous coding agent running in an unpac workspace. 7 156 8 157 Unpac is a monorepo vendoring tool that uses git worktrees to manage dependencies. 9 158 It supports two backends: ··· 25 174 - Explore vendored code 26 175 - Make local patches 27 176 - Merge dependencies into projects 177 + - Analyze and improve code quality 178 + - Update STATUS.md documentation 28 179 29 180 Always use the provided tools to interact with unpac. Query the workspace state 30 181 before making changes to understand the current configuration. ··· 105 256 106 257 Buffer.contents buf 107 258 108 - let generate ~proc_mgr ~root = 109 - let buf = Buffer.create 8192 in 259 + let generate ~proc_mgr ~root ~autonomous = 260 + let buf = Buffer.create 16384 in 110 261 let add s = Buffer.add_string buf s in 111 262 112 - add base_prompt; 263 + (* Choose base prompt based on mode *) 264 + if autonomous then 265 + add autonomous_base_prompt 266 + else 267 + add interactive_base_prompt; 268 + 113 269 add "\n\n---\n\n"; 114 270 115 271 (* Add CLI help if available *)
+9 -3
lib/claude/prompt.mli
··· 8 8 val generate : 9 9 proc_mgr:Unpac.Git.proc_mgr -> 10 10 root:Unpac.Worktree.root -> 11 + autonomous:bool -> 11 12 string 12 - (** Generate a system prompt with full unpac knowledge. *) 13 + (** Generate a system prompt with full unpac knowledge. 14 + If [autonomous] is true, includes detailed instructions for autonomous 15 + code maintenance and improvement. *) 13 16 14 - val base_prompt : string 15 - (** Base system prompt explaining the agent's role. *) 17 + val autonomous_base_prompt : string 18 + (** Base system prompt for autonomous mode. *) 19 + 20 + val interactive_base_prompt : string 21 + (** Base system prompt for interactive mode. *)
+453 -10
lib/claude/tools.ml
··· 1 - (** Tool definitions for Claude to interact with unpac. *) 1 + (** Tool definitions for Claude to interact with unpac and analyze code. *) 2 2 3 3 let src = Logs.Src.create "unpac.claude.tools" ~doc:"Claude tools" 4 4 module Log = (val Logs.src_log src : Logs.LOG) ··· 6 6 type tool_result = 7 7 | Success of string 8 8 | Error of string 9 + 10 + (* Helper to truncate long output *) 11 + let truncate_output ?(max_len=50000) s = 12 + if String.length s > max_len then 13 + String.sub s 0 max_len ^ "\n\n[... truncated ...]" 14 + else s 9 15 10 16 (* Git list tool *) 11 17 let git_list ~proc_mgr ~root = ··· 131 137 if String.trim diff = "" then 132 138 Success (Printf.sprintf "No local changes in '%s'." name) 133 139 else 134 - Success (Printf.sprintf "Diff for '%s':\n\n%s" name diff) 140 + Success (truncate_output (Printf.sprintf "Diff for '%s':\n\n%s" name diff)) 135 141 end 136 142 with exn -> 137 143 Error (Printf.sprintf "Failed to get diff for '%s': %s" name (Printexc.to_string exn)) ··· 198 204 with exn -> 199 205 Error (Printf.sprintf "Failed to get status: %s" (Printexc.to_string exn)) 200 206 207 + (* === NEW FILE OPERATION TOOLS === *) 208 + 209 + (* Read file tool *) 210 + let read_file ~fs ~path = 211 + try 212 + let full_path = Eio.Path.(fs / path) in 213 + if not (Eio.Path.is_file full_path) then 214 + Error (Printf.sprintf "File not found: %s" path) 215 + else begin 216 + let content = Eio.Path.load full_path in 217 + Success (truncate_output content) 218 + end 219 + with exn -> 220 + Error (Printf.sprintf "Failed to read '%s': %s" path (Printexc.to_string exn)) 221 + 222 + (* Write file tool *) 223 + let write_file ~fs ~path ~content = 224 + try 225 + let full_path = Eio.Path.(fs / path) in 226 + (* Ensure parent directory exists *) 227 + let parent = Filename.dirname path in 228 + if parent <> "." && parent <> "/" then begin 229 + let parent_path = Eio.Path.(fs / parent) in 230 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 parent_path 231 + end; 232 + Eio.Path.save ~create:(`Or_truncate 0o644) full_path content; 233 + Success (Printf.sprintf "Successfully wrote %d bytes to %s" (String.length content) path) 234 + with exn -> 235 + Error (Printf.sprintf "Failed to write '%s': %s" path (Printexc.to_string exn)) 236 + 237 + (* List directory tool *) 238 + let list_dir ~fs ~path = 239 + try 240 + let full_path = Eio.Path.(fs / path) in 241 + if not (Eio.Path.is_directory full_path) then 242 + Error (Printf.sprintf "Not a directory: %s" path) 243 + else begin 244 + let entries = Eio.Path.read_dir full_path in 245 + let entries = List.sort String.compare entries in 246 + let buf = Buffer.create 256 in 247 + Buffer.add_string buf (Printf.sprintf "Contents of %s:\n" path); 248 + List.iter (fun e -> 249 + let entry_path = Eio.Path.(full_path / e) in 250 + let suffix = if Eio.Path.is_directory entry_path then "/" else "" in 251 + Buffer.add_string buf (Printf.sprintf " %s%s\n" e suffix) 252 + ) entries; 253 + Success (Buffer.contents buf) 254 + end 255 + with exn -> 256 + Error (Printf.sprintf "Failed to list '%s': %s" path (Printexc.to_string exn)) 257 + 258 + (* Glob files tool *) 259 + let glob_files ~fs ~pattern ~base_path = 260 + try 261 + let full_base = Eio.Path.(fs / base_path) in 262 + if not (Eio.Path.is_directory full_base) then 263 + Error (Printf.sprintf "Base path not a directory: %s" base_path) 264 + else begin 265 + let results = ref [] in 266 + let rec walk dir rel_path = 267 + let entries = try Eio.Path.read_dir dir with _ -> [] in 268 + List.iter (fun name -> 269 + let entry_path = Eio.Path.(dir / name) in 270 + let rel = if rel_path = "" then name else rel_path ^ "/" ^ name in 271 + if Eio.Path.is_directory entry_path then 272 + walk entry_path rel 273 + else begin 274 + (* Simple glob matching - support *.ml, **/*.ml patterns *) 275 + let matches = 276 + if String.starts_with ~prefix:"**/" pattern then 277 + let ext = String.sub pattern 3 (String.length pattern - 3) in 278 + String.ends_with ~suffix:ext name 279 + else if String.starts_with ~prefix:"*" pattern then 280 + let ext = String.sub pattern 1 (String.length pattern - 1) in 281 + String.ends_with ~suffix:ext name 282 + else 283 + name = pattern 284 + in 285 + if matches then results := rel :: !results 286 + end 287 + ) entries 288 + in 289 + walk full_base ""; 290 + let files = List.sort String.compare !results in 291 + if files = [] then 292 + Success (Printf.sprintf "No files matching '%s' in %s" pattern base_path) 293 + else begin 294 + let buf = Buffer.create 256 in 295 + Buffer.add_string buf (Printf.sprintf "Files matching '%s' in %s:\n" pattern base_path); 296 + List.iter (fun f -> Buffer.add_string buf (Printf.sprintf " %s\n" f)) files; 297 + Success (Buffer.contents buf) 298 + end 299 + end 300 + with exn -> 301 + Error (Printf.sprintf "Failed to glob '%s': %s" pattern (Printexc.to_string exn)) 302 + 303 + (* === SHELL EXECUTION TOOL === *) 304 + 305 + let run_shell ~proc_mgr ~fs ~cwd ~command ~timeout_sec:_ = 306 + try 307 + let result = Eio.Switch.run @@ fun sw -> 308 + let stdout_buf = Buffer.create 4096 in 309 + let stderr_buf = Buffer.create 4096 in 310 + let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 311 + let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 312 + 313 + let cwd_path = Eio.Path.(fs / cwd) in 314 + 315 + let child = Eio.Process.spawn proc_mgr ~sw 316 + ~cwd:(cwd_path :> Eio.Fs.dir_ty Eio.Path.t) 317 + ~stdout:stdout_w ~stderr:stderr_w 318 + ["sh"; "-c"; command] 319 + in 320 + Eio.Flow.close stdout_w; 321 + Eio.Flow.close stderr_w; 322 + 323 + (* Read output concurrently *) 324 + Eio.Fiber.both 325 + (fun () -> 326 + let chunk = Cstruct.create 4096 in 327 + let rec loop () = 328 + match Eio.Flow.single_read stdout_r chunk with 329 + | n -> 330 + Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); 331 + loop () 332 + | exception End_of_file -> () 333 + in loop ()) 334 + (fun () -> 335 + let chunk = Cstruct.create 4096 in 336 + let rec loop () = 337 + match Eio.Flow.single_read stderr_r chunk with 338 + | n -> 339 + Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); 340 + loop () 341 + | exception End_of_file -> () 342 + in loop ()); 343 + 344 + let status = Eio.Process.await child in 345 + let stdout = Buffer.contents stdout_buf in 346 + let stderr = Buffer.contents stderr_buf in 347 + (status, stdout, stderr) 348 + in 349 + let (status, stdout, stderr) = result in 350 + let exit_code = match status with 351 + | `Exited c -> c 352 + | `Signaled s -> 128 + s 353 + in 354 + let buf = Buffer.create 256 in 355 + Buffer.add_string buf (Printf.sprintf "Exit code: %d\n" exit_code); 356 + if stdout <> "" then begin 357 + Buffer.add_string buf "\n=== STDOUT ===\n"; 358 + Buffer.add_string buf stdout 359 + end; 360 + if stderr <> "" then begin 361 + Buffer.add_string buf "\n=== STDERR ===\n"; 362 + Buffer.add_string buf stderr 363 + end; 364 + if exit_code = 0 then 365 + Success (truncate_output (Buffer.contents buf)) 366 + else 367 + Error (truncate_output (Buffer.contents buf)) 368 + with exn -> 369 + Error (Printf.sprintf "Failed to run command: %s" (Printexc.to_string exn)) 370 + 371 + (* === UNPAC SYNC TOOLS === *) 372 + 373 + let unpac_status_sync ~proc_mgr ~root = 374 + try 375 + let main_wt = Unpac.Worktree.path root Unpac.Worktree.Main in 376 + let result = Eio.Switch.run @@ fun sw -> 377 + let stdout_buf = Buffer.create 4096 in 378 + let stderr_buf = Buffer.create 4096 in 379 + let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 380 + let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 381 + 382 + let child = Eio.Process.spawn proc_mgr ~sw 383 + ~cwd:(main_wt :> Eio.Fs.dir_ty Eio.Path.t) 384 + ~stdout:stdout_w ~stderr:stderr_w 385 + ["unpac"; "status"] 386 + in 387 + Eio.Flow.close stdout_w; 388 + Eio.Flow.close stderr_w; 389 + 390 + Eio.Fiber.both 391 + (fun () -> 392 + let chunk = Cstruct.create 4096 in 393 + let rec loop () = 394 + match Eio.Flow.single_read stdout_r chunk with 395 + | n -> Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); loop () 396 + | exception End_of_file -> () 397 + in loop ()) 398 + (fun () -> 399 + let chunk = Cstruct.create 4096 in 400 + let rec loop () = 401 + match Eio.Flow.single_read stderr_r chunk with 402 + | n -> Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); loop () 403 + | exception End_of_file -> () 404 + in loop ()); 405 + 406 + ignore (Eio.Process.await child); 407 + Buffer.contents stdout_buf 408 + in 409 + Success (Printf.sprintf "Ran unpac status:\n%s" (truncate_output result)) 410 + with exn -> 411 + Error (Printf.sprintf "Failed to run unpac status: %s" (Printexc.to_string exn)) 412 + 413 + let unpac_push ~proc_mgr ~root ~remote = 414 + try 415 + let main_wt = Unpac.Worktree.path root Unpac.Worktree.Main in 416 + let result = Eio.Switch.run @@ fun sw -> 417 + let stdout_buf = Buffer.create 4096 in 418 + let stderr_buf = Buffer.create 4096 in 419 + let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 420 + let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 421 + 422 + let child = Eio.Process.spawn proc_mgr ~sw 423 + ~cwd:(main_wt :> Eio.Fs.dir_ty Eio.Path.t) 424 + ~stdout:stdout_w ~stderr:stderr_w 425 + ["unpac"; "push"; remote] 426 + in 427 + Eio.Flow.close stdout_w; 428 + Eio.Flow.close stderr_w; 429 + 430 + Eio.Fiber.both 431 + (fun () -> 432 + let chunk = Cstruct.create 4096 in 433 + let rec loop () = 434 + match Eio.Flow.single_read stdout_r chunk with 435 + | n -> Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); loop () 436 + | exception End_of_file -> () 437 + in loop ()) 438 + (fun () -> 439 + let chunk = Cstruct.create 4096 in 440 + let rec loop () = 441 + match Eio.Flow.single_read stderr_r chunk with 442 + | n -> Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); loop () 443 + | exception End_of_file -> () 444 + in loop ()); 445 + 446 + let status = Eio.Process.await child in 447 + let stdout = Buffer.contents stdout_buf in 448 + let stderr = Buffer.contents stderr_buf in 449 + (status, stdout, stderr) 450 + in 451 + let (status, stdout, stderr) = result in 452 + let exit_code = match status with `Exited c -> c | `Signaled s -> 128 + s in 453 + if exit_code = 0 then 454 + Success (Printf.sprintf "Pushed to %s:\n%s" remote (truncate_output stdout)) 455 + else 456 + Error (Printf.sprintf "Push failed (exit %d):\n%s\n%s" exit_code stdout stderr) 457 + with exn -> 458 + Error (Printf.sprintf "Failed to push: %s" (Printexc.to_string exn)) 459 + 460 + (* === GIT COMMIT TOOL === *) 461 + 462 + let git_commit ~proc_mgr ~cwd ~message = 463 + try 464 + let result = Eio.Switch.run @@ fun sw -> 465 + (* First add all changes *) 466 + let add_child = Eio.Process.spawn proc_mgr ~sw 467 + ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) 468 + ["git"; "add"; "-A"] 469 + in 470 + let add_status = Eio.Process.await add_child in 471 + (match add_status with 472 + | `Exited 0 -> () 473 + | _ -> failwith "git add failed"); 474 + 475 + (* Then commit *) 476 + let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 477 + let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 478 + let stdout_buf = Buffer.create 256 in 479 + let stderr_buf = Buffer.create 256 in 480 + 481 + let commit_child = Eio.Process.spawn proc_mgr ~sw 482 + ~cwd:(cwd :> Eio.Fs.dir_ty Eio.Path.t) 483 + ~stdout:stdout_w ~stderr:stderr_w 484 + ["git"; "commit"; "-m"; message] 485 + in 486 + Eio.Flow.close stdout_w; 487 + Eio.Flow.close stderr_w; 488 + 489 + Eio.Fiber.both 490 + (fun () -> 491 + let chunk = Cstruct.create 1024 in 492 + let rec loop () = 493 + match Eio.Flow.single_read stdout_r chunk with 494 + | n -> Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); loop () 495 + | exception End_of_file -> () 496 + in loop ()) 497 + (fun () -> 498 + let chunk = Cstruct.create 1024 in 499 + let rec loop () = 500 + match Eio.Flow.single_read stderr_r chunk with 501 + | n -> Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)); loop () 502 + | exception End_of_file -> () 503 + in loop ()); 504 + 505 + let status = Eio.Process.await commit_child in 506 + (status, Buffer.contents stdout_buf, Buffer.contents stderr_buf) 507 + in 508 + let (status, stdout, stderr) = result in 509 + match status with 510 + | `Exited 0 -> Success (Printf.sprintf "Committed:\n%s" stdout) 511 + | `Exited 1 when String.length stdout > 0 -> 512 + Success "Nothing to commit (working tree clean)" 513 + | _ -> Error (Printf.sprintf "Commit failed:\n%s\n%s" stdout stderr) 514 + with exn -> 515 + Error (Printf.sprintf "Failed to commit: %s" (Printexc.to_string exn)) 516 + 201 517 (* Tool schemas for Claude *) 202 518 let tool_schemas : (string * string * Jsont.json) list = 203 519 let meta = Jsont.Meta.none in 204 520 let str s = Jsont.String (s, meta) in 521 + let _int_t n = Jsont.Number (float_of_int n, meta) in 205 522 let arr l = Jsont.Array (l, meta) in 206 523 let obj l = Jsont.Object (List.map (fun (k, v) -> ((k, meta), v)) l, meta) in 207 - let string_prop _name desc = 524 + let string_prop desc = 208 525 obj [("type", str "string"); ("description", str desc)] 209 526 in 527 + let int_prop desc = 528 + obj [("type", str "integer"); ("description", str desc)] 529 + in 210 530 let make_schema required_props props = 211 531 obj [ 212 532 ("type", str "object"); ··· 215 535 ] 216 536 in 217 537 [ 538 + (* Workspace status tools *) 218 539 ("unpac_status", 219 540 "Get an overview of the unpac workspace, including all projects, \ 220 541 vendored git repositories, and opam packages.", 221 542 make_schema [] []); 222 543 544 + ("unpac_status_sync", 545 + "Run 'unpac status' to update README.md and sync workspace state. \ 546 + Call this periodically to keep the workspace documentation current.", 547 + make_schema [] []); 548 + 549 + ("unpac_push", 550 + "Push all branches to the remote repository. Call this after making \ 551 + changes to sync with the remote.", 552 + make_schema ["remote"] [ 553 + ("remote", string_prop "Remote name to push to (usually 'origin')"); 554 + ]); 555 + 556 + (* Git vendoring tools *) 223 557 ("unpac_git_list", 224 558 "List all vendored git repositories in the workspace.", 225 559 make_schema [] []); ··· 228 562 "Vendor a new git repository. Clones the repo and creates the three-tier \ 229 563 branch structure for conflict-free vendoring with full history preservation.", 230 564 make_schema ["url"] [ 231 - ("url", string_prop "url" "Git URL to clone from"); 232 - ("name", string_prop "name" "Override repository name (default: derived from URL)"); 233 - ("branch", string_prop "branch" "Git branch or tag to vendor (default: remote default)"); 234 - ("subdir", string_prop "subdir" "Extract only this subdirectory from the repository"); 565 + ("url", string_prop "Git URL to clone from"); 566 + ("name", string_prop "Override repository name (default: derived from URL)"); 567 + ("branch", string_prop "Git branch or tag to vendor (default: remote default)"); 568 + ("subdir", string_prop "Extract only this subdirectory from the repository"); 235 569 ]); 236 570 237 571 ("unpac_git_info", 238 572 "Show detailed information about a vendored git repository, including \ 239 573 branch SHAs and number of local commits.", 240 574 make_schema ["name"] [ 241 - ("name", string_prop "name" "Name of the vendored repository"); 575 + ("name", string_prop "Name of the vendored repository"); 242 576 ]); 243 577 244 578 ("unpac_git_diff", 245 579 "Show the diff between vendor and patches branches for a git repository. \ 246 580 This shows what local modifications have been made.", 247 581 make_schema ["name"] [ 248 - ("name", string_prop "name" "Name of the vendored repository"); 582 + ("name", string_prop "Name of the vendored repository"); 249 583 ]); 250 584 585 + (* Opam tools *) 251 586 ("unpac_opam_list", 252 587 "List all vendored opam packages in the workspace.", 253 588 make_schema [] []); ··· 255 590 ("unpac_project_list", 256 591 "List all projects in the workspace.", 257 592 make_schema [] []); 593 + 594 + (* File operation tools *) 595 + ("read_file", 596 + "Read the contents of a file. Use this to analyze source code, \ 597 + STATUS.md files, test files, etc.", 598 + make_schema ["path"] [ 599 + ("path", string_prop "Absolute or relative path to the file"); 600 + ]); 601 + 602 + ("write_file", 603 + "Write content to a file. Use this to update STATUS.md, fix code, \ 604 + add tests, etc. Parent directories are created if needed.", 605 + make_schema ["path"; "content"] [ 606 + ("path", string_prop "Path to write to"); 607 + ("content", string_prop "Content to write"); 608 + ]); 609 + 610 + ("list_directory", 611 + "List the contents of a directory.", 612 + make_schema ["path"] [ 613 + ("path", string_prop "Path to the directory"); 614 + ]); 615 + 616 + ("glob_files", 617 + "Find files matching a glob pattern. Supports *.ml, **/*.ml patterns.", 618 + make_schema ["pattern"; "base_path"] [ 619 + ("pattern", string_prop "Glob pattern (e.g., '*.ml', '**/*.mli')"); 620 + ("base_path", string_prop "Base directory to search from"); 621 + ]); 622 + 623 + (* Shell execution *) 624 + ("run_shell", 625 + "Execute a shell command. Use for building (dune build), testing \ 626 + (dune test), or other operations. Be careful with destructive commands.", 627 + make_schema ["command"; "cwd"] [ 628 + ("command", string_prop "Shell command to execute"); 629 + ("cwd", string_prop "Working directory for the command"); 630 + ("timeout_sec", int_prop "Timeout in seconds (default: 300)"); 631 + ]); 632 + 633 + (* Git commit *) 634 + ("git_commit", 635 + "Stage all changes and create a git commit with the given message.", 636 + make_schema ["cwd"; "message"] [ 637 + ("cwd", string_prop "Working directory (should be a git repo)"); 638 + ("message", string_prop "Commit message"); 639 + ]); 258 640 ] 259 641 260 642 (* Execute tool by name *) ··· 264 646 let get_string key = 265 647 match input with 266 648 | Jsont.Object (members, _) -> 267 - (* Members are ((string * meta), json) pairs *) 268 649 let rec find = function 269 650 | [] -> None 270 651 | ((k, _meta), v) :: rest -> ··· 278 659 | _ -> None 279 660 in 280 661 662 + let get_int key default = 663 + match input with 664 + | Jsont.Object (members, _) -> 665 + let rec find = function 666 + | [] -> default 667 + | ((k, _meta), v) :: rest -> 668 + if k = key then 669 + match v with 670 + | Jsont.Number (n, _) -> int_of_float n 671 + | _ -> default 672 + else find rest 673 + in 674 + find members 675 + | _ -> default 676 + in 677 + 281 678 match tool_name with 282 679 | "unpac_status" -> 283 680 status ~proc_mgr ~root 284 681 682 + | "unpac_status_sync" -> 683 + unpac_status_sync ~proc_mgr ~root 684 + 685 + | "unpac_push" -> 686 + (match get_string "remote" with 687 + | None -> Error "Missing required parameter: remote" 688 + | Some remote -> unpac_push ~proc_mgr ~root ~remote) 689 + 285 690 | "unpac_git_list" -> 286 691 git_list ~proc_mgr ~root 287 692 ··· 309 714 310 715 | "unpac_project_list" -> 311 716 project_list ~proc_mgr ~root 717 + 718 + | "read_file" -> 719 + (match get_string "path" with 720 + | None -> Error "Missing required parameter: path" 721 + | Some path -> read_file ~fs ~path) 722 + 723 + | "write_file" -> 724 + (match get_string "path", get_string "content" with 725 + | None, _ -> Error "Missing required parameter: path" 726 + | _, None -> Error "Missing required parameter: content" 727 + | Some path, Some content -> write_file ~fs ~path ~content) 728 + 729 + | "list_directory" -> 730 + (match get_string "path" with 731 + | None -> Error "Missing required parameter: path" 732 + | Some path -> list_dir ~fs ~path) 733 + 734 + | "glob_files" -> 735 + (match get_string "pattern", get_string "base_path" with 736 + | None, _ -> Error "Missing required parameter: pattern" 737 + | _, None -> Error "Missing required parameter: base_path" 738 + | Some pattern, Some base_path -> glob_files ~fs ~pattern ~base_path) 739 + 740 + | "run_shell" -> 741 + (match get_string "command", get_string "cwd" with 742 + | None, _ -> Error "Missing required parameter: command" 743 + | _, None -> Error "Missing required parameter: cwd" 744 + | Some command, Some cwd -> 745 + let timeout_sec = get_int "timeout_sec" 300 in 746 + run_shell ~proc_mgr ~fs ~cwd ~command ~timeout_sec) 747 + 748 + | "git_commit" -> 749 + (match get_string "cwd", get_string "message" with 750 + | None, _ -> Error "Missing required parameter: cwd" 751 + | _, None -> Error "Missing required parameter: message" 752 + | Some cwd, Some message -> 753 + let cwd_path = Eio.Path.(fs / cwd) in 754 + git_commit ~proc_mgr ~cwd:cwd_path ~message) 312 755 313 756 | _ -> 314 757 Error (Printf.sprintf "Unknown tool: %s" tool_name)