@@ -4,22 +4,17 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- "io"
87 "log/slog"
98 "os"
10- "path/filepath"
119 "strings"
1210 "time"
1311
1412 "github.com/spf13/cobra"
1513
1614 "github.com/GrayCodeAI/trace/cli/agent/spawn"
1715 "github.com/GrayCodeAI/trace/cli/agent/types"
18- "github.com/GrayCodeAI/trace/cli/checkpoint/id"
19- "github.com/GrayCodeAI/trace/cli/gitexec"
2016 "github.com/GrayCodeAI/trace/cli/interactive"
2117 "github.com/GrayCodeAI/trace/cli/logging"
22- "github.com/GrayCodeAI/trace/cli/mdrender"
2318 "github.com/GrayCodeAI/trace/cli/paths"
2419 "github.com/GrayCodeAI/trace/cli/session"
2520 "github.com/GrayCodeAI/trace/cli/settings"
@@ -816,342 +811,3 @@ func resolveRunConfig(cfg *settings.InvestigateConfig, f runFlags) (agents []str
816811 }
817812 return agents , maxTurns , quorum , nil
818813}
819-
820- // parseAgentsCSV splits a comma-separated agent list, trimming whitespace
821- // and dropping empty entries.
822- func parseAgentsCSV (csv string ) []string {
823- parts := strings .Split (csv , "," )
824- out := make ([]string , 0 , len (parts ))
825- for _ , p := range parts {
826- if v := strings .TrimSpace (p ); v != "" {
827- out = append (out , v )
828- }
829- }
830- return out
831- }
832-
833- // verifyAgentsLaunchable confirms each agent has a non-nil Spawner AND has
834- // hooks installed in the current repo.
835- func verifyAgentsLaunchable (ctx context.Context , agents []string , deps Deps ) error {
836- if deps .SpawnerFor == nil {
837- return errors .New ("investigate: SpawnerFor not wired" )
838- }
839- if deps .GetAgentsWithHooksInstalled == nil {
840- return errors .New ("investigate: GetAgentsWithHooksInstalled not wired" )
841- }
842- installed := deps .GetAgentsWithHooksInstalled (ctx )
843- installedSet := make (map [string ]struct {}, len (installed ))
844- for _ , n := range installed {
845- installedSet [string (n )] = struct {}{}
846- }
847- for _ , name := range agents {
848- if deps .SpawnerFor (name ) == nil {
849- return fmt .Errorf ("agent %q is not launchable (spawner missing)" , name )
850- }
851- if _ , ok := installedSet [name ]; ! ok {
852- return fmt .Errorf ("agent %q is not launchable (run `entire configure --agent %s` first)" , name , name )
853- }
854- }
855- return nil
856- }
857-
858- // resolveTopicAndSeed turns the user's input args into a topic + (seed
859- // doc path | issue link seed bytes + topic). pickerPrompt is the
860- // "Investigation prompt" collected from the spawn-time multipicker; it
861- // becomes the topic only when no seed-doc / --issue-link was supplied.
862- // Exactly one of seedDoc / issueSeed / topic-only is set on return.
863- func resolveTopicAndSeed (ctx context.Context , args []string , f runFlags , pickerPrompt string ) (topic , seedDoc string , issueSeed []byte , issueTopic string , err error ) {
864- switch {
865- case len (args ) == 1 :
866- seedDoc = args [0 ]
867- body , readErr := os .ReadFile (seedDoc ) //nolint:gosec // path is user-supplied positional arg
868- if readErr != nil {
869- return "" , "" , nil , "" , fmt .Errorf ("read seed doc %s: %w" , seedDoc , readErr )
870- }
871- topic = DeriveTopicFromSeed (body , seedDoc )
872- return topic , seedDoc , nil , "" , nil
873- case strings .TrimSpace (f .issueLink ) != "" :
874- res , resErr := ResolveIssueLink (ctx , f .issueLink )
875- if resErr != nil {
876- return "" , "" , nil , "" , resErr
877- }
878- return res .Topic , "" , res .SeedDoc , res .Topic , nil
879- case strings .TrimSpace (pickerPrompt ) != "" :
880- topic = strings .TrimSpace (pickerPrompt )
881- return topic , "" , nil , "" , nil
882- default :
883- return "" , "" , nil , "" , errors .New ("missing investigation input: pass [seed-doc] or --issue-link, or enter an investigation prompt in the picker" )
884- }
885- }
886-
887- // topicForBootstrap returns the topic value to embed in the bootstrap
888- // scaffold. The seed-doc path takes precedence (Bootstrap re-derives from
889- // the seed body), and the issue-link path uses IssueLinkTopic; only the
890- // topic-only path puts the resolved topic into BootstrapInput.Topic.
891- func topicForBootstrap (topic , seedDoc string , issueSeed []byte ) string {
892- if seedDoc != "" || len (issueSeed ) > 0 {
893- return ""
894- }
895- return topic
896- }
897-
898- // resolveDocPaths returns the absolute findings path for a run. The
899- // findings doc lives alongside state.json in the per-run directory under
900- // the git common dir:
901- //
902- // <commonDir>/trace-investigations/<run-id>/findings.md
903- // <commonDir>/trace-investigations/<run-id>/state.json
904- //
905- // Putting the per-run artefacts under the git common dir (rather than the
906- // worktree's .trace/investigations/) keeps the worktree's working tree
907- // clean — investigation findings are session-scoped scratch space, not
908- // part of the user's source tree.
909- func resolveDocPaths (commonDir , runID string ) string {
910- return filepath .Join (commonDir , InvestigationsDirName , runID , "findings.md" )
911- }
912-
913- // executeLoopAndCapture runs the loop and returns the LoopResult so the
914- // caller can use it to compose a post-run manifest / footer.
915- func executeLoopAndCapture (ctx context.Context , cmd * cobra.Command , in LoopInput , deps Deps ) (LoopResult , error ) {
916- stateStore , err := NewStateStore (ctx )
917- if err != nil {
918- return LoopResult {}, fmt .Errorf ("open run state store: %w" , err )
919- }
920-
921- out := cmd .OutOrStdout ()
922- progress , tuiSink , runCtx , cancelTUI := buildProgressSink (ctx , in , out )
923- // Defers run LIFO. Register Wait first so cancelTUI fires BEFORE Wait
924- // — Wait blocks on the Bubble Tea program exiting, and the ctx-watcher
925- // in Start() needs ctx cancelled to push tea.Quit when no RunFinished
926- // arrives (early loop return, validation error, etc.).
927- if tuiSink != nil {
928- tuiSink .Start (runCtx )
929- defer tuiSink .Wait ()
930- }
931- if cancelTUI != nil {
932- defer cancelTUI ()
933- }
934-
935- ldeps := LoopDeps {
936- SpawnerFor : deps .SpawnerFor ,
937- States : stateStore ,
938- Progress : progress ,
939- }
940-
941- runner := deps .LoopRun
942- if runner == nil {
943- runner = RunInvestigateLoop
944- }
945- result , runErr := runner (runCtx , in , ldeps )
946- if runErr != nil {
947- return result , fmt .Errorf ("investigate loop: %w" , runErr )
948- }
949- return result , nil
950- }
951-
952- // buildProgressSink chooses between the Bubble Tea TUI and the plain-text
953- // fallback based on terminal capability. In TTY mode ctx is wrapped in a
954- // cancellable child so the in-TUI Ctrl+C handler can stop the run via the
955- // same cancel function the cobra root would use on SIGINT. In non-TTY mode
956- // the caller's ctx is returned unchanged and cancelTUI is nil.
957- func buildProgressSink (ctx context.Context , in LoopInput , out io.Writer ) (ProgressSink , * tuiProgressSink , context.Context , context.CancelFunc ) { //nolint:ireturn // returns interface by design
958- if ! interactive .IsTerminalWriter (out ) || ! interactive .CanPromptInteractively () {
959- return newTextProgressSink (out ), nil , ctx , nil
960- }
961- runCtx , cancel := context .WithCancel (ctx )
962- maxTurns := in .MaxTurns
963- if maxTurns == 0 {
964- maxTurns = defaultMaxTurns
965- }
966- quorum := in .Quorum
967- if quorum == 0 {
968- quorum = len (in .Agents )
969- }
970- sink := newTUIProgressSink (in .Topic , in .RunID , in .Agents , maxTurns , quorum , cancel , out )
971- return sink , sink , runCtx , cancel
972- }
973-
974- // writeRunManifest builds a LocalManifest from the loop result and
975- // persists it. Failures are logged but do not error — the docs themselves
976- // are the deliverable.
977- //
978- // On terminal outcomes (Quorum/Stalled) the manifest captures the final
979- // findings.md content into FindingsContent and the per-run directory is
980- // removed — the manifest becomes the durable record of the run. On
981- // Paused/Cancelled the per-run directory is left in place so `--continue`
982- // can pick up where the run left off.
983- func writeRunManifest (
984- ctx context.Context ,
985- out io.Writer ,
986- runID , topic string ,
987- agents []string ,
988- startingSHA , worktreePath , findingsDoc string ,
989- startedAt , endedAt time.Time ,
990- result LoopResult ,
991- ) {
992- manifestStore , err := NewLocalManifestStore (ctx )
993- if err != nil {
994- logging .Debug (ctx , "investigate: open manifest store" ,
995- slog .String ("err" , err .Error ()), slog .String ("run_id" , runID ))
996- return
997- }
998- stancesByAgent := map [string ]string {}
999- if result .State != nil {
1000- for _ , s := range result .State .Stances {
1001- stancesByAgent [s .Agent ] = s .Stance
1002- }
1003- }
1004- if startedAt .IsZero () && result .State != nil {
1005- startedAt = result .State .StartedAt
1006- }
1007- if endedAt .IsZero () {
1008- endedAt = time .Now ().UTC ()
1009- }
1010-
1011- // Capture findings into the manifest on terminal outcomes so the
1012- // content survives even after the per-run dir is deleted. Failure to
1013- // read is logged but non-fatal — the manifest still records that
1014- // the run happened, just without the findings body. The per-run dir
1015- // is NOT cleaned up if the read fails: leaving the file behind gives
1016- // the user a chance to recover it manually.
1017- terminal := result .Outcome == OutcomeQuorum || result .Outcome == OutcomeStalled
1018- findingsContent := ""
1019- captured := false
1020- if terminal && findingsDoc != "" {
1021- data , readErr := os .ReadFile (findingsDoc ) //nolint:gosec // path computed from runID + git common dir
1022- if readErr != nil {
1023- logging .Debug (ctx , "investigate: read findings for manifest capture" ,
1024- slog .String ("err" , readErr .Error ()), slog .String ("run_id" , runID ))
1025- } else {
1026- findingsContent = string (data )
1027- captured = true
1028- }
1029- }
1030-
1031- m := LocalManifest {
1032- RunID : runID ,
1033- Topic : topic ,
1034- Slug : SlugifyTopic (topic ),
1035- StartingSHA : startingSHA ,
1036- WorktreePath : worktreePath ,
1037- FindingsDoc : findingsDoc ,
1038- FindingsContent : findingsContent ,
1039- Agents : append ([]string (nil ), agents ... ),
1040- Outcome : string (result .Outcome ),
1041- StancesByAgent : stancesByAgent ,
1042- StartedAt : startedAt ,
1043- EndedAt : endedAt ,
1044- }
1045- if writeErr := manifestStore .Write (ctx , m ); writeErr != nil {
1046- logging .Debug (ctx , "investigate: manifest write failed" ,
1047- slog .String ("err" , writeErr .Error ()), slog .String ("run_id" , runID ))
1048- return
1049- }
1050-
1051- // Clean up the per-run dir only AFTER the manifest write succeeds
1052- // and only when the findings body was captured. This keeps failure
1053- // modes safe: a manifest write failure leaves the per-run dir intact
1054- // (for retry/inspection); a read failure leaves the file on disk so
1055- // the user can recover it.
1056- if terminal && captured && findingsDoc != "" {
1057- runDir := filepath .Dir (findingsDoc )
1058- if rmErr := os .RemoveAll (runDir ); rmErr != nil {
1059- logging .Debug (ctx , "investigate: cleanup per-run dir" ,
1060- slog .String ("err" , rmErr .Error ()), slog .String ("run_id" , runID ))
1061- }
1062- }
1063-
1064- writeInvestigateFooter (out , m )
1065- }
1066-
1067- // writeInvestigateFooter prints the post-run summary, the findings
1068- // content, and how to run `trace investigate fix`. The findings
1069- // content comes from the manifest's embedded FindingsContent on
1070- // terminal outcomes (Quorum/Stalled — the per-run dir is gone); on
1071- // paused/cancelled outcomes findings.md is read from the per-run dir.
1072- func writeInvestigateFooter (w io.Writer , m LocalManifest ) {
1073- fmt .Fprintln (w )
1074- if m .Outcome != "" {
1075- fmt .Fprintf (w , "Outcome: %s\n " , m .Outcome )
1076- }
1077- // Quorum/Stalled are terminal (per-run dir cleaned, findings captured);
1078- // Paused/Cancelled are resumable. "complete" would mislead users into
1079- // thinking a paused run can't be picked up.
1080- switch m .Outcome {
1081- case string (OutcomePaused ), string (OutcomeCancelled ):
1082- fmt .Fprintln (w , "Investigation ended (resumable with `trace investigate --continue " + m .RunID + "`)." )
1083- default :
1084- fmt .Fprintln (w , "Investigation complete." )
1085- }
1086- fmt .Fprintln (w )
1087-
1088- body := findingsContentFor (m )
1089- if body != "" {
1090- rendered , renderErr := mdrender .RenderForWriter (w , body )
1091- if renderErr != nil {
1092- // Fall back to raw markdown when glamour fails (malformed
1093- // style config, unexpected runtime).
1094- rendered = body
1095- }
1096- fmt .Fprint (w , rendered )
1097- if ! strings .HasSuffix (rendered , "\n " ) {
1098- fmt .Fprintln (w )
1099- }
1100- fmt .Fprintln (w )
1101- }
1102-
1103- // For terminal outcomes, suggest `fix` (which feeds findings into a
1104- // coding agent). For paused/cancelled, `fix` would launch off stale
1105- // partial findings; the resume hint above is the right next step
1106- // instead.
1107- switch m .Outcome {
1108- case string (OutcomePaused ), string (OutcomeCancelled ):
1109- // Resume hint already emitted above.
1110- default :
1111- fmt .Fprintln (w , "To apply these findings:" )
1112- fmt .Fprintf (w , " trace investigate fix %s\n " , m .RunID )
1113- }
1114- }
1115-
1116- // findingsContentFor returns the findings body to render in the footer.
1117- // Prefers the manifest's embedded content (set on terminal outcomes
1118- // when the per-run dir has been cleaned); falls back to reading the
1119- // on-disk findings.md for paused/cancelled outcomes. Errors and
1120- // missing files both yield "" — the caller prints a shorter footer.
1121- func findingsContentFor (m LocalManifest ) string {
1122- if m .FindingsContent != "" {
1123- return m .FindingsContent
1124- }
1125- if m .FindingsDoc == "" {
1126- return ""
1127- }
1128- data , err := os .ReadFile (m .FindingsDoc )
1129- if err != nil {
1130- return ""
1131- }
1132- return string (data )
1133- }
1134-
1135- // newRunID returns a fresh 12-hex-char run identifier, sharing the
1136- // checkpoint-id format used by the strategy package.
1137- func newRunID () (string , error ) {
1138- cid , err := id .Generate ()
1139- if err != nil {
1140- return "" , fmt .Errorf ("generate run ID: %w" , err )
1141- }
1142- return cid .String (), nil
1143- }
1144-
1145- // currentHeadSHA returns the current HEAD commit hash as a 40-char hex
1146- // string.
1147- func currentHeadSHA (ctx context.Context , repoRoot string ) (string , error ) {
1148- return gitexec .HeadSHA (ctx , repoRoot ) //nolint:wrapcheck // gitexec already wraps
1149- }
1150-
1151- // wrapSilent applies the silent-error wrapper if it is non-nil.
1152- func wrapSilent (fn func (error ) error , err error ) error {
1153- if fn == nil {
1154- return err
1155- }
1156- return fn (err )
1157- }
0 commit comments