A monorepo management tool for the agentic ages

Add persistent audit logging with auto-commit to git

The audit system now:
- Records all major operations (project new, opam add/update/merge,
git add/update/merge)
- Automatically saves audit log to .unpac-audit.json in main worktree
- Auto-commits the audit log after each operation so it persists on push
- Provides log viewing with `unpac log` command
- Supports JSON export with `unpac log --json`
- Supports HTML report generation with `unpac log --html -o report.html`

The audit manager wraps operations and handles:
- Starting/ending operation context
- Appending entries to the log file
- Committing the log to git (main branch)

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

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

+212
+26
bin/main.ml
··· 22 22 | Some root -> 23 23 f ~env ~fs ~proc_mgr ~root 24 24 25 + (* Helper to wrap operations with audit logging *) 26 + let with_audit ~proc_mgr ~root ~operation_type ~args f = 27 + let main_wt = Unpac.Worktree.path root Unpac.Worktree.Main in 28 + let mgr = Unpac.Audit.create_manager ~proc_mgr ~main_wt in 29 + let ctx = Unpac.Audit.begin_operation mgr ~operation_type ~args in 30 + try 31 + let result = f ctx in 32 + ignore (Unpac.Audit.end_success mgr); 33 + result 34 + with exn -> 35 + ignore (Unpac.Audit.end_failed mgr ~error:(Printexc.to_string exn)); 36 + raise exn 37 + 25 38 (* Helper to get config path *) 26 39 let config_path root = 27 40 let main_path = Unpac.Worktree.path root Unpac.Worktree.Main in ··· 166 179 in 167 180 let run () name = 168 181 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 182 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Project_new ~args:[name] @@ fun _ctx -> 169 183 let _path = Unpac.Init.create_project ~proc_mgr root name in 170 184 Format.printf "Created project %s@." name; 171 185 Format.printf "@.Next steps:@."; ··· 357 371 with_root @@ fun ~env:_ ~fs ~proc_mgr ~root -> 358 372 let config = load_config root in 359 373 let cache = resolve_cache ~proc_mgr ~fs ~config ~cli_cache in 374 + 375 + (* Wrap entire operation with audit logging *) 376 + let args = [pkg] @ (match name_opt with Some n -> ["--name"; n] | None -> []) 377 + @ (if solve then ["--solve"] else []) in 378 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Opam_add ~args @@ fun _ctx -> 360 379 361 380 if solve then begin 362 381 (* Solve dependencies and add all packages *) ··· 588 607 in 589 608 let run () name = 590 609 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 610 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Opam_update ~args:[name] @@ fun _ctx -> 591 611 match Unpac_opam.Opam.update_package ~proc_mgr ~root name with 592 612 | Unpac.Backend.Updated { name = pkg_name; old_sha; new_sha } -> 593 613 Format.printf "Updated %s: %s -> %s@." pkg_name ··· 624 644 let run () args all solve = 625 645 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 626 646 let config = load_config root in 647 + let audit_args = args @ (if all then ["--all"] else []) @ (if solve then ["--solve"] else []) in 648 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Opam_merge ~args:audit_args @@ fun _ctx -> 627 649 628 650 let merge_one ~project pkg = 629 651 let patches_branch = Unpac_opam.Opam.patches_branch pkg in ··· 928 950 with_root @@ fun ~env:_ ~fs ~proc_mgr ~root -> 929 951 let config = load_config root in 930 952 let cache = resolve_cache ~proc_mgr ~fs ~config ~cli_cache in 953 + let audit_args = [url] @ (match name_opt with Some n -> ["--name"; n] | None -> []) in 954 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Git_add ~args:audit_args @@ fun _ctx -> 931 955 932 956 let name = match name_opt with 933 957 | Some n -> n ··· 987 1011 in 988 1012 let run () name = 989 1013 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1014 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Git_update ~args:[name] @@ fun _ctx -> 990 1015 match Unpac.Git_backend.update_repo ~proc_mgr ~root name with 991 1016 | Unpac.Backend.Updated { name = repo_name; old_sha; new_sha } -> 992 1017 Format.printf "Updated %s: %s -> %s@." repo_name ··· 1013 1038 in 1014 1039 let run () name project = 1015 1040 with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1041 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Git_merge ~args:[name; project] @@ fun _ctx -> 1016 1042 let patches_branch = Unpac.Git_backend.patches_branch name in 1017 1043 match Unpac.Backend.merge_to_project ~proc_mgr ~root ~project ~patches_branch with 1018 1044 | Ok () ->
+149
lib/audit.ml
··· 62 62 let current_version = "1.0" 63 63 64 64 (* UUID generation - simple random hex *) 65 + let () = Random.self_init () 66 + 65 67 let generate_id () = 66 68 let buf = Buffer.create 32 in 67 69 for _ = 1 to 8 do ··· 338 340 | c -> Buffer.add_char buf c 339 341 ) s; 340 342 Buffer.contents buf 343 + 344 + (* Commit audit log to git *) 345 + 346 + let commit_log ~proc_mgr ~main_wt ~log_path = 347 + (* Stage the audit log *) 348 + let rel_path = Filename.basename log_path in 349 + let started = Unix.gettimeofday () in 350 + let result = 351 + try 352 + (* Add the file *) 353 + Eio.Switch.run @@ fun sw -> 354 + let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 355 + let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 356 + let child = Eio.Process.spawn proc_mgr ~sw 357 + ~cwd:(main_wt :> Eio.Fs.dir_ty Eio.Path.t) 358 + ~stdout:stdout_w ~stderr:stderr_w 359 + ["git"; "add"; rel_path] 360 + in 361 + Eio.Flow.close stdout_w; 362 + Eio.Flow.close stderr_w; 363 + (* Drain outputs *) 364 + let stdout_buf = Buffer.create 64 in 365 + let stderr_buf = Buffer.create 64 in 366 + Eio.Fiber.both 367 + (fun () -> 368 + try 369 + while true do 370 + let chunk = Cstruct.create 1024 in 371 + let n = Eio.Flow.single_read stdout_r chunk in 372 + Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 373 + done 374 + with End_of_file -> ()) 375 + (fun () -> 376 + try 377 + while true do 378 + let chunk = Cstruct.create 1024 in 379 + let n = Eio.Flow.single_read stderr_r chunk in 380 + Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 381 + done 382 + with End_of_file -> ()); 383 + let status = Eio.Process.await child in 384 + match status with 385 + | `Exited 0 -> Ok () 386 + | `Exited code -> Error (Printf.sprintf "git add failed (exit %d): %s" code (Buffer.contents stderr_buf)) 387 + | `Signaled sig_ -> Error (Printf.sprintf "git add killed by signal %d" sig_) 388 + with exn -> Error (Printf.sprintf "Exception: %s" (Printexc.to_string exn)) 389 + in 390 + match result with 391 + | Error e -> 392 + Log.warn (fun m -> m "Failed to stage audit log: %s" e); 393 + Error e 394 + | Ok () -> 395 + (* Commit the file *) 396 + let result = 397 + try 398 + Eio.Switch.run @@ fun sw -> 399 + let stdout_r, stdout_w = Eio.Process.pipe proc_mgr ~sw in 400 + let stderr_r, stderr_w = Eio.Process.pipe proc_mgr ~sw in 401 + let child = Eio.Process.spawn proc_mgr ~sw 402 + ~cwd:(main_wt :> Eio.Fs.dir_ty Eio.Path.t) 403 + ~stdout:stdout_w ~stderr:stderr_w 404 + ["git"; "commit"; "-m"; "Update audit log"; "--no-verify"] 405 + in 406 + Eio.Flow.close stdout_w; 407 + Eio.Flow.close stderr_w; 408 + (* Drain outputs *) 409 + let stdout_buf = Buffer.create 64 in 410 + let stderr_buf = Buffer.create 64 in 411 + Eio.Fiber.both 412 + (fun () -> 413 + try 414 + while true do 415 + let chunk = Cstruct.create 1024 in 416 + let n = Eio.Flow.single_read stdout_r chunk in 417 + Buffer.add_string stdout_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 418 + done 419 + with End_of_file -> ()) 420 + (fun () -> 421 + try 422 + while true do 423 + let chunk = Cstruct.create 1024 in 424 + let n = Eio.Flow.single_read stderr_r chunk in 425 + Buffer.add_string stderr_buf (Cstruct.to_string (Cstruct.sub chunk 0 n)) 426 + done 427 + with End_of_file -> ()); 428 + let status = Eio.Process.await child in 429 + match status with 430 + | `Exited 0 -> Ok () 431 + | `Exited 1 when String.length (Buffer.contents stdout_buf) > 0 && 432 + (String.exists (fun c -> c = 'n') (Buffer.contents stdout_buf)) -> 433 + (* "nothing to commit" - this is fine *) 434 + Ok () 435 + | `Exited code -> Error (Printf.sprintf "git commit failed (exit %d): %s" code (Buffer.contents stderr_buf)) 436 + | `Signaled sig_ -> Error (Printf.sprintf "git commit killed by signal %d" sig_) 437 + with exn -> Error (Printf.sprintf "Exception: %s" (Printexc.to_string exn)) 438 + in 439 + let duration = int_of_float ((Unix.gettimeofday () -. started) *. 1000.0) in 440 + (match result with 441 + | Ok () -> Log.debug (fun m -> m "Committed audit log (%dms)" duration) 442 + | Error e -> Log.warn (fun m -> m "Failed to commit audit log: %s" e)); 443 + result 444 + 445 + (** Full audit manager that wraps operations *) 446 + type manager = { 447 + proc_mgr : [ `Generic | `Unix ] Eio.Process.mgr_ty Eio.Resource.t; 448 + main_wt : Eio.Fs.dir_ty Eio.Path.t; 449 + log_path : string; 450 + mutable current_ctx : context option; 451 + } 452 + 453 + let create_manager ~proc_mgr ~main_wt = 454 + let log_path = Eio.Path.(main_wt / default_log_file) |> snd in 455 + { proc_mgr; main_wt; log_path; current_ctx = None } 456 + 457 + let begin_operation mgr ~operation_type ~args = 458 + let cwd = snd mgr.main_wt in 459 + let ctx = start_operation ~operation_type ~args ~cwd in 460 + mgr.current_ctx <- Some ctx; 461 + ctx 462 + 463 + let end_operation mgr status = 464 + match mgr.current_ctx with 465 + | None -> 466 + Log.warn (fun m -> m "end_operation called without active context"); 467 + Error "No active operation" 468 + | Some ctx -> 469 + mgr.current_ctx <- None; 470 + let op = finalize_operation ctx status in 471 + (* Append to log file *) 472 + (match append mgr.log_path op with 473 + | Error e -> 474 + Log.err (fun m -> m "Failed to append to audit log: %s" e); 475 + Error e 476 + | Ok () -> 477 + (* Commit the log *) 478 + match commit_log ~proc_mgr:mgr.proc_mgr ~main_wt:mgr.main_wt ~log_path:mgr.log_path with 479 + | Error e -> 480 + Log.warn (fun m -> m "Failed to commit audit log (will retry next operation): %s" e); 481 + Ok op (* Still return success - the log is saved, just not committed *) 482 + | Ok () -> 483 + Ok op) 484 + 485 + let end_success mgr = end_operation mgr Success 486 + let end_failed mgr ~error = end_operation mgr (Failed error) 487 + let end_conflict mgr ~files = end_operation mgr (Conflict files) 488 + 489 + let get_context mgr = mgr.current_ctx 341 490 342 491 let to_html log = 343 492 let buf = Buffer.create 4096 in
+37
lib/audit.mli
··· 142 142 143 143 (** Generate HTML report from log *) 144 144 val to_html : log -> string 145 + 146 + (** {1 Audit Manager} *) 147 + 148 + (** Manager that handles full operation lifecycle with auto-commit *) 149 + type manager 150 + 151 + (** Create an audit manager for the given workspace *) 152 + val create_manager : 153 + proc_mgr:[ `Generic | `Unix ] Eio.Process.mgr_ty Eio.Resource.t -> 154 + main_wt:Eio.Fs.dir_ty Eio.Path.t -> 155 + manager 156 + 157 + (** Begin a new audited operation. Returns the context for recording git ops. *) 158 + val begin_operation : 159 + manager -> 160 + operation_type:operation_type -> 161 + args:string list -> 162 + context 163 + 164 + (** End an operation successfully. Appends to log and commits. *) 165 + val end_success : manager -> (operation, string) result 166 + 167 + (** End an operation with failure. Appends to log and commits. *) 168 + val end_failed : manager -> error:string -> (operation, string) result 169 + 170 + (** End an operation with merge conflict. Appends to log and commits. *) 171 + val end_conflict : manager -> files:string list -> (operation, string) result 172 + 173 + (** Get the current context if one is active *) 174 + val get_context : manager -> context option 175 + 176 + (** Commit the audit log to git *) 177 + val commit_log : 178 + proc_mgr:[ `Generic | `Unix ] Eio.Process.mgr_ty Eio.Resource.t -> 179 + main_wt:Eio.Fs.dir_ty Eio.Path.t -> 180 + log_path:string -> 181 + (unit, string) result