From d12741b04ba652dbeecd547745e128b4640b2fd5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:14:43 +0530 Subject: [PATCH 1/7] docs(examples): rewrite README with correct CLI commands The examples README referenced non-existent commands (trace start, trace stop, trace list, trace show, etc.). Rewrite with the actual CLI surface: trace enable, trace status, trace session list/info/resume/export/replay, trace checkpoint list/rewind, trace fork, trace investigate. --- examples/README.md | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/examples/README.md b/examples/README.md index dcef772..b450b62 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,21 +2,24 @@ Trace captures AI coding sessions as git-native checkpoints. +> **Note:** Trace is surfaced through hawk as `hawk trace ...`. The commands +> below use `trace` for brevity; prefix with `hawk` when running inside hawk. + ## Basic Usage -### Start a tracing session +### Enable tracing ```bash -trace start -# Work with your AI coding assistant -trace stop +trace enable +# Work with your AI coding assistant — checkpoints are captured automatically +trace status ``` ### View captured sessions ```bash -trace list -trace show +trace session list +trace session info ``` ### Investigate what happened @@ -30,28 +33,42 @@ trace investigate ### Rewind to a checkpoint ```bash -trace checkpoints -trace rewind --checkpoint 3 +trace checkpoint list +trace checkpoint rewind --checkpoint 3 ``` ### Resume a session ```bash -trace resume +trace session resume ``` ### Export session data ```bash -trace export --format json +trace session export --format json +``` + +### Fork a session for A/B testing + +```bash +trace fork --checkpoint 3 +``` + +### Replay a session + +```bash +trace session replay ``` ## Integration with Agents Trace works with: - Claude Code +- Codex CLI +- Gemini CLI - Cursor -- GitHub Copilot +- GitHub Copilot CLI - Any MCP-compatible agent See the [main README](../README.md) for full documentation. From 4f56f219166f05efe7d5e21785ed5686e0e2160e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 20:42:09 +0530 Subject: [PATCH 2/7] perf(cli): make version check async to eliminate command latency The version check in PersistentPostRun was synchronous with a 2s timeout, adding up to 2 seconds of latency to every command invocation. Run it in a goroutine so it never blocks command completion. --- cli/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/root.go b/cli/root.go index 4966115..70345d9 100644 --- a/cli/root.go +++ b/cli/root.go @@ -75,9 +75,9 @@ func NewRootCmd() *cobra.Command { telemetry.TrackCommandDetached(cmd, agentStr, settings.Enabled, versioninfo.Version) } - // Version check and notification (synchronous with 2s timeout) + // Version check and notification (async to avoid adding latency) // Runs AFTER command completes to avoid interfering with interactive modes - versioncheck.CheckAndNotify(cmd.Context(), cmd.OutOrStdout(), versioninfo.Version) + go versioncheck.CheckAndNotify(cmd.Context(), cmd.OutOrStdout(), versioninfo.Version) }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() From f26bc69d7032d8053476d23b7bfd5d7ec4f380e5 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Thu, 23 Jul 2026 21:35:31 +0530 Subject: [PATCH 3/7] feat(session list): add --json flag for machine-readable session output Adds a --json flag to 'trace session list' that outputs the filtered session list as indented JSON instead of styled cards. Mirrors the existing --json support on 'trace session info'. --- cli/sessions.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/cli/sessions.go b/cli/sessions.go index fcab39a..cd02903 100644 --- a/cli/sessions.go +++ b/cli/sessions.go @@ -194,6 +194,7 @@ func sessionPhaseLabel(s *strategy.SessionState) string { func newListCmd() *cobra.Command { var tagFilters []string + var jsonFlag bool cmd := &cobra.Command{ Use: "list", @@ -204,13 +205,15 @@ For active sessions only, use 'trace status'. Examples: trace sessions list List all sessions across all worktrees - trace sessions list --tag project=my-app Filter sessions by tag key=value`, + trace sessions list --tag project=my-app Filter sessions by tag key=value + trace sessions list --json Output as JSON`, RunE: func(cmd *cobra.Command, _ []string) error { - return runSessionList(cmd.Context(), cmd, tagFilters) + return runSessionList(cmd.Context(), cmd, tagFilters, jsonFlag) }, } cmd.Flags().StringSliceVar(&tagFilters, "tag", nil, "Filter by tag (key=value); matches TRACE_TAG_ session metadata") + cmd.Flags().BoolVar(&jsonFlag, "json", false, "output sessions as JSON") return cmd } @@ -234,7 +237,7 @@ func matchTagFilters(metadata map[string]string, filters []string) bool { return true } -func runSessionList(ctx context.Context, cmd *cobra.Command, tagFilters []string) error { +func runSessionList(ctx context.Context, cmd *cobra.Command, tagFilters []string, jsonOutput bool) error { states, err := strategy.ListSessionStates(ctx) if err != nil { return fmt.Errorf("failed to list sessions: %w", err) @@ -253,6 +256,12 @@ func runSessionList(ctx context.Context, cmd *cobra.Command, tagFilters []string w := cmd.OutOrStdout() + if jsonOutput { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(filtered) + } + if len(filtered) == 0 { fmt.Fprintln(w, "No sessions.") return nil From 6353b09126ce37c4e9463abfecbc8b978b0c2fbf Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Fri, 24 Jul 2026 12:22:00 +0530 Subject: [PATCH 4/7] feat(cli): add --json to recap/plugin list/agent list, delete dead mcp/ pkg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --json flag to `trace recap` emitting MeRecapResponse as indented JSON (encoder writes to cmd.OutOrStdout(), consistent with sessions list). - Add --json flag to `trace plugin list` emitting []InstalledPlugin. - Add --json flag to `trace agent list` emitting [{name, installed}] entries. - Add JSON tags to InstalledPlugin (Name, Path, Symlink, LinkTarget). - Delete the dead mcp/ package — a basic JSON-RPC MCP server that was never imported or referenced anywhere in the codebase. - Add tests: JSON output paths for recap (mock HTTP server + git repo), plugin list, and agent list. Update existing agent list tests for the new jsonOut parameter. Update flag-registration test to include --json. All cli, cli/recap, and cli/api tests pass. Co-Authored-By: Grok --- cli/agent_group.go | 25 +++++++-- cli/agent_group_test.go | 41 ++++++++++++++- cli/plugin_group.go | 15 ++++-- cli/plugin_group_test.go | 21 ++++++++ cli/plugin_store.go | 8 +-- cli/recap.go | 8 +++ cli/recap_test.go | 46 ++++++++++++++++- mcp/server.go | 85 ------------------------------ mcp/server_test.go | 108 --------------------------------------- 9 files changed, 151 insertions(+), 206 deletions(-) delete mode 100644 mcp/server.go delete mode 100644 mcp/server_test.go diff --git a/cli/agent_group.go b/cli/agent_group.go index 8512478..642bcbf 100644 --- a/cli/agent_group.go +++ b/cli/agent_group.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -56,16 +57,19 @@ func runAgentMenu(ctx context.Context, w io.Writer) error { } func newAgentListCmd() *cobra.Command { - return &cobra.Command{ + var jsonOut bool + cmd := &cobra.Command{ Use: "list", Short: "List installed and available agents", RunE: func(cmd *cobra.Command, _ []string) error { - return runAgentList(cmd.Context(), cmd.OutOrStdout()) + return runAgentList(cmd.Context(), cmd.OutOrStdout(), jsonOut) }, } + cmd.Flags().BoolVar(&jsonOut, "json", false, "output agent list as JSON") + return cmd } -func runAgentList(ctx context.Context, w io.Writer) error { +func runAgentList(ctx context.Context, w io.Writer, jsonOut bool) error { installed := GetAgentsWithHooksInstalled(ctx) installedSet := make(map[types.AgentName]struct{}, len(installed)) for _, name := range installed { @@ -74,6 +78,21 @@ func runAgentList(ctx context.Context, w io.Writer) error { all := agent.StringList() + if jsonOut { + type agentEntry struct { + Name string `json:"name"` + Installed bool `json:"installed"` + } + entries := make([]agentEntry, 0, len(all)) + for _, name := range all { + _, ok := installedSet[types.AgentName(name)] + entries = append(entries, agentEntry{Name: name, Installed: ok}) + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(entries) + } + fmt.Fprintln(w, "Agents:") for _, name := range all { marker := " " diff --git a/cli/agent_group_test.go b/cli/agent_group_test.go index e638ae3..74d2e06 100644 --- a/cli/agent_group_test.go +++ b/cli/agent_group_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "context" + "encoding/json" "strings" "testing" @@ -14,7 +15,7 @@ func TestRunAgentList_ListsAvailableAgents(t *testing.T) { t.Parallel() var buf bytes.Buffer - if err := runAgentList(context.Background(), &buf); err != nil { + if err := runAgentList(context.Background(), &buf, false); err != nil { t.Fatalf("runAgentList: %v", err) } out := buf.String() @@ -44,7 +45,7 @@ func TestRunAgentList_MarksInstalledWithCheck(t *testing.T) { t.Parallel() var buf bytes.Buffer - if err := runAgentList(context.Background(), &buf); err != nil { + if err := runAgentList(context.Background(), &buf, false); err != nil { t.Fatalf("runAgentList: %v", err) } out := buf.String() @@ -57,6 +58,42 @@ func TestRunAgentList_MarksInstalledWithCheck(t *testing.T) { } } +func TestRunAgentList_JSONOutput(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := runAgentList(context.Background(), &buf, true); err != nil { + t.Fatalf("runAgentList --json: %v", err) + } + + var entries []struct { + Name string `json:"name"` + Installed bool `json:"installed"` + } + if err := json.Unmarshal(buf.Bytes(), &entries); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, buf.String()) + } + if len(entries) == 0 { + t.Fatalf("expected at least one agent entry, got none") + } + registered := agent.StringList() + found := false + for _, name := range registered { + for _, e := range entries { + if e.Name == name { + found = true + break + } + } + if found { + break + } + } + if !found { + t.Errorf("none of registered agents %v appeared in JSON output", registered) + } +} + func TestAgentGroupBareCommandRunsAgentMenu(t *testing.T) { // t.Chdir cannot coexist with t.Parallel; this test mutates process CWD. dir := t.TempDir() diff --git a/cli/plugin_group.go b/cli/plugin_group.go index e76abc1..e541813 100644 --- a/cli/plugin_group.go +++ b/cli/plugin_group.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "fmt" "io" @@ -106,20 +107,28 @@ func warnIfShadowsBuiltin(cmd *cobra.Command, name string) { } func newPluginListCmd() *cobra.Command { - return &cobra.Command{ + var jsonOut bool + cmd := &cobra.Command{ Use: "list", Short: "List plugins installed in the managed directory", RunE: func(cmd *cobra.Command, _ []string) error { - return runPluginList(cmd.OutOrStdout()) + return runPluginList(cmd.OutOrStdout(), jsonOut) }, } + cmd.Flags().BoolVar(&jsonOut, "json", false, "output plugin list as JSON") + return cmd } -func runPluginList(w io.Writer) error { +func runPluginList(w io.Writer, jsonOut bool) error { plugins, err := ListInstalledPlugins() if err != nil { return fmt.Errorf("list plugins: %w", err) } + if jsonOut { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(plugins) + } dir, err := PluginBinDir() if err != nil { return fmt.Errorf("plugin bin dir: %w", err) diff --git a/cli/plugin_group_test.go b/cli/plugin_group_test.go index 930394c..2147d2a 100644 --- a/cli/plugin_group_test.go +++ b/cli/plugin_group_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "strings" "testing" @@ -52,3 +53,23 @@ func TestWarnIfShadowsBuiltin(t *testing.T) { }) } } + +func TestRunPluginList_JSONOutput(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + if err := runPluginList(&buf, true); err != nil { + t.Fatalf("runPluginList --json: %v", err) + } + + var plugins []InstalledPlugin + if err := json.Unmarshal(buf.Bytes(), &plugins); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, buf.String()) + } + // Empty or populated, the output must always be a JSON array. + for _, p := range plugins { + if p.Name == "" { + t.Errorf("plugin entry with empty name in JSON output") + } + } +} diff --git a/cli/plugin_store.go b/cli/plugin_store.go index 6fa7389..262ddbb 100644 --- a/cli/plugin_store.go +++ b/cli/plugin_store.go @@ -226,13 +226,13 @@ func pathEntriesEqual(a, b string) bool { type InstalledPlugin struct { // Name is the bare plugin name (without the `trace-` prefix and any // platform-specific extension). - Name string + Name string `json:"name"` // Path is the absolute path inside the managed bin dir. - Path string + Path string `json:"path"` // Symlink is true when Path is a symlink to a source location elsewhere // (the typical local-dev install). LinkTarget is populated in that case. - Symlink bool - LinkTarget string + Symlink bool `json:"symlink"` + LinkTarget string `json:"linkTarget,omitempty"` } // ListInstalledPlugins enumerates entries in the managed bin dir whose name diff --git a/cli/recap.go b/cli/recap.go index c452099..fdf0db2 100644 --- a/cli/recap.go +++ b/cli/recap.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -29,6 +30,7 @@ type recapFlags struct { color string static bool insecureHTTP bool + json bool } const ( @@ -55,6 +57,7 @@ func newRecapCmd() *cobra.Command { cmd.Flags().StringVar(&f.color, "color", recapColorAuto, "Color output: auto, always, or never") cmd.Flags().BoolVar(&f.static, "static", false, "Print static output instead of opening the interactive recap") cmd.Flags().BoolVar(&f.insecureHTTP, "insecure-http-auth", false, "Allow plain-HTTP auth (local dev only)") + cmd.Flags().BoolVar(&f.json, "json", false, "output recap as JSON") cmd.MarkFlagsMutuallyExclusive("day", "week", "month", "90") return cmd } @@ -152,6 +155,11 @@ func runRecap(ctx context.Context, w, errW io.Writer, f *recapFlags) error { if err != nil { return handleRecapFetchError(errW, err) } + if f.json { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(resp) + } fmt.Fprint(w, recap.RenderStaticRecap(resp, recap.RenderOptions{ Range: rangeKey, View: mode, diff --git a/cli/recap_test.go b/cli/recap_test.go index 99b2c7d..fc21edf 100644 --- a/cli/recap_test.go +++ b/cli/recap_test.go @@ -3,16 +3,19 @@ package cli import ( "bytes" "context" + "encoding/json" "errors" "fmt" "net" "net/http" + "net/http/httptest" "net/url" "strings" "testing" "github.com/GrayCodeAI/trace/cli/api" "github.com/GrayCodeAI/trace/cli/recap" + "github.com/GrayCodeAI/trace/cli/testutil" ) const recapTestAgentCodex = "codex" @@ -66,7 +69,7 @@ func TestRecapFlags_Mode(t *testing.T) { func TestRecapCmd_RegistersStaticFlags(t *testing.T) { t.Parallel() cmd := newRecapCmd() - for _, name := range []string{"day", "week", "month", "90", "agent", "view", "color", "static", "insecure-http-auth"} { + for _, name := range []string{"day", "week", "month", "90", "agent", "view", "color", "static", "insecure-http-auth", "json"} { if flag := cmd.Flag(name); flag == nil { t.Errorf("flag --%s not registered", name) } @@ -387,3 +390,44 @@ func TestRecapLoadErrorMessage_ContextDeadlineExceeded(t *testing.T) { t.Fatalf("message missing timeout explanation:\n%s", got) } } + +func TestRunRecap_JSONOutput(t *testing.T) { + // t.Chdir cannot coexist with t.Parallel; this test mutates process CWD. + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "timeframe": "day", + "since": "2026-05-08T00:00:00Z", + "until": "2026-05-09T00:00:00Z", + "agents": {}, + "summary": {"me": {"sessions": 1, "checkpoints": 2, "tokens": 3}, "repoCount": 1, "activeDays": 1, "analysis": {"complete": 1, "pending": 0, "failed": 0}}, + "daily": [], + "updated_at": "2026-05-08T12:00:00Z" + }`)) + })) + defer server.Close() + t.Setenv(api.BaseURLEnvVar, server.URL) + t.Setenv("TRACE_TOKEN", "test-token") + + var out bytes.Buffer + var errOut bytes.Buffer + err := runRecap(context.Background(), &out, &errOut, &recapFlags{json: true}) + if err != nil { + t.Fatalf("runRecap --json: %v\nstderr: %s", err, errOut.String()) + } + + var resp recap.MeRecapResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out.String()) + } + if resp.Timeframe != "day" { + t.Errorf("timeframe = %q, want day", resp.Timeframe) + } + if resp.Summary.Me.Sessions != 1 { + t.Errorf("summary.me.sessions = %d, want 1", resp.Summary.Me.Sessions) + } +} diff --git a/mcp/server.go b/mcp/server.go deleted file mode 100644 index 4b5c45d..0000000 --- a/mcp/server.go +++ /dev/null @@ -1,85 +0,0 @@ -package mcp - -import ( - "encoding/json" - "fmt" -) - -// Tool represents an MCP tool with a name, description, and handler function. -type Tool struct { - Name string - Description string - Handler func(params json.RawMessage) (json.RawMessage, error) -} - -// Server is a basic MCP server that manages tools and routes requests. -type Server struct { - tools map[string]*Tool -} - -// NewServer creates and returns a new Server. -func NewServer() *Server { - return &Server{ - tools: make(map[string]*Tool), - } -} - -// RegisterTool adds a tool to the server's registry. -func (s *Server) RegisterTool(t *Tool) { - s.tools[t.Name] = t -} - -// ListTools returns all registered tools. -func (s *Server) ListTools() []*Tool { - result := make([]*Tool, 0, len(s.tools)) - for _, t := range s.tools { - result = append(result, t) - } - return result -} - -// Request represents a JSON-RPC style request with a method and optional params. -type Request struct { - Method string `json:"method"` - Params json.RawMessage `json:"params"` -} - -// Response represents a JSON-RPC style response. -type Response struct { - Result interface{} `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// HandleRequest routes a request to the appropriate handler based on method. -func (s *Server) HandleRequest(req *Request) *Response { - switch req.Method { - case "tools/list": - return &Response{Result: s.ListTools()} - case "tools/call": - return s.handleToolsCall(req.Params) - default: - return &Response{Error: fmt.Sprintf("unknown method: %s", req.Method)} - } -} - -// toolsCallParams holds the parameters for a tools/call request. -type toolsCallParams struct { - Name string `json:"name"` - Params json.RawMessage `json:"params"` -} - -func (s *Server) handleToolsCall(params json.RawMessage) *Response { - var p toolsCallParams - if err := json.Unmarshal(params, &p); err != nil { - return &Response{Error: fmt.Sprintf("invalid params: %v", err)} - } - t, ok := s.tools[p.Name] - if !ok { - return &Response{Error: fmt.Sprintf("tool not found: %s", p.Name)} - } - result, err := t.Handler(p.Params) - if err != nil { - return &Response{Error: fmt.Sprintf("handler error: %v", err)} - } - return &Response{Result: result} -} diff --git a/mcp/server_test.go b/mcp/server_test.go deleted file mode 100644 index c00776b..0000000 --- a/mcp/server_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package mcp - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewServerCreatesServer(t *testing.T) { - s := NewServer() - assert.NotNil(t, s) - assert.NotNil(t, s.tools) -} - -func TestRegisterToolAddsTool(t *testing.T) { - s := NewServer() - tool := &Tool{ - Name: "echo", - Description: "Echoes input back", - Handler: func(params json.RawMessage) (json.RawMessage, error) { - return params, nil - }, - } - s.RegisterTool(tool) - - registered, ok := s.tools["echo"] - require.True(t, ok) - assert.Equal(t, "echo", registered.Name) - assert.Equal(t, "Echoes input back", registered.Description) -} - -func TestListToolsReturnsRegisteredTools(t *testing.T) { - s := NewServer() - s.RegisterTool(&Tool{Name: "a", Description: "tool a", Handler: func(p json.RawMessage) (json.RawMessage, error) { return p, nil }}) - s.RegisterTool(&Tool{Name: "b", Description: "tool b", Handler: func(p json.RawMessage) (json.RawMessage, error) { return p, nil }}) - s.RegisterTool(&Tool{Name: "c", Description: "tool c", Handler: func(p json.RawMessage) (json.RawMessage, error) { return p, nil }}) - - tools := s.ListTools() - assert.Len(t, tools, 3) - - names := make(map[string]bool) - for _, t := range tools { - names[t.Name] = true - } - assert.True(t, names["a"]) - assert.True(t, names["b"]) - assert.True(t, names["c"]) -} - -func TestHandleRequestToolsList(t *testing.T) { - s := NewServer() - s.RegisterTool(&Tool{Name: "foo", Description: "foo tool", Handler: func(p json.RawMessage) (json.RawMessage, error) { return p, nil }}) - s.RegisterTool(&Tool{Name: "bar", Description: "bar tool", Handler: func(p json.RawMessage) (json.RawMessage, error) { return p, nil }}) - - resp := s.HandleRequest(&Request{Method: "tools/list"}) - assert.Empty(t, resp.Error) - - // Result should be the list of tools. - tools, ok := resp.Result.([]*Tool) - require.True(t, ok, "expected []*Tool result") - assert.Len(t, tools, 2) -} - -func TestHandleRequestToolsCallRoutesToHandler(t *testing.T) { - s := NewServer() - s.RegisterTool(&Tool{ - Name: "greet", - Description: "Greets someone", - Handler: func(params json.RawMessage) (json.RawMessage, error) { - var input struct { - Name string `json:"name"` - } - if err := json.Unmarshal(params, &input); err != nil { - return nil, err - } - return json.Marshal(map[string]string{"greeting": "hello " + input.Name}) - }, - }) - - callParams, err := json.Marshal(toolsCallParams{ - Name: "greet", - Params: json.RawMessage(`{"name":"world"}`), - }) - require.NoError(t, err) - - resp := s.HandleRequest(&Request{ - Method: "tools/call", - Params: callParams, - }) - require.Empty(t, resp.Error) - - resultBytes, err := json.Marshal(resp.Result) - require.NoError(t, err) - - var out map[string]string - require.NoError(t, json.Unmarshal(resultBytes, &out)) - assert.Equal(t, "hello world", out["greeting"]) -} - -func TestHandleRequestUnknownMethod(t *testing.T) { - s := NewServer() - - resp := s.HandleRequest(&Request{Method: "foo/bar"}) - assert.Equal(t, "unknown method: foo/bar", resp.Error) - assert.Nil(t, resp.Result) -} From b299dd5aa0410793b38d73b5e0ad11213d180e0a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 12:31:17 +0530 Subject: [PATCH 5/7] feat: add graph command module - Add cli/graph_cmd.go with graph command - Add cli/graph_cmd_test.go with test coverage - Update cli/root.go, root_test.go, go.mod - Update README --- README.md | 25 +- cli/graph_cmd.go | 553 ++++++++++++++++++++++++++++++++++++++++++ cli/graph_cmd_test.go | 228 +++++++++++++++++ cli/root.go | 1 + cli/root_test.go | 13 + go.mod | 3 + 6 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 cli/graph_cmd.go create mode 100644 cli/graph_cmd_test.go diff --git a/README.md b/README.md index 2fc4f83..809a6e2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Trace hooks into your Git workflow to capture AI agent sessions as you work. Ses Trace is a Hawk support engine. Keep the dependency edge one-way: -- trace uses local-only types (trace/redaction event types are trace-scoped, not shared contracts) +- trace keeps redaction and storage types local; portable graph exports use + `hawk-core-contracts/graph` - do not import `hawk/internal/*` - do not import removed legacy path `hawk/shared/types` - do not import other engines (`eyrie`, `yaad`, `tok`, `sight`, `inspect`) — engines are peers, not dependencies @@ -164,6 +165,8 @@ trace disable # Removes hooks, code untouched | `trace checkpoint` | List, explain, rewind, search checkpoints | | `trace checkpoint rewind` | Rewind to a previous checkpoint | | `trace checkpoint explain` | Explain a session or checkpoint | +| `trace graph export` | Export sessions and checkpoints as portable graph nodes, edges, and events | +| `trace graph correlation --hawk-session ` | Resolve exact Trace session/checkpoint IDs captured for a Hawk persisted session | | `trace fork` | Clone a checkpoint into a new independent session for A/B testing | | `trace annotate` | Attach a comment to a session or checkpoint | | `trace ci-init` | Configure Trace to auto-capture sessions in CI | @@ -176,6 +179,26 @@ trace disable # Removes hooks, code untouched | `trace login` | Authenticate with Trace | | `trace version` | Show CLI version | +### Hawk correlation + +Set `TRACE_TAG_HAWK_SESSION_ID` to the Hawk persisted-session ID before Trace's +session-start hook runs. Trace stores it through the existing session-tag +mechanism: + +```bash +TRACE_TAG_HAWK_SESSION_ID=hawk-session-123 +trace graph correlation --hawk-session hawk-session-123 +``` + +The correlation command is read-only. It returns `trace.correlation/v1` JSON +with every exact Trace session match and its committed checkpoint IDs. +`checkpoint_lookup_complete` states whether checkpoint enumeration succeeded; +session identity remains usable when it is false, but consumers must ignore +the checkpoint list. A missing mapping produces `"matches": []`; Trace never +guesses from a branch, commit, timestamp, prompt, or similar-looking session +ID. The response exposes only correlation IDs and lifecycle fields, not +arbitrary session metadata. + Run `trace --help` for detailed usage. --- diff --git a/cli/graph_cmd.go b/cli/graph_cmd.go new file mode 100644 index 0000000..0e56aee --- /dev/null +++ b/cli/graph_cmd.go @@ -0,0 +1,553 @@ +package cli + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/trace/cli/checkpoint" + "github.com/GrayCodeAI/trace/cli/paths" + "github.com/GrayCodeAI/trace/cli/session" + "github.com/GrayCodeAI/trace/cli/strategy" + "github.com/spf13/cobra" +) + +const traceGraphSchemaVersion = "hawk.graph/v1" +const traceCorrelationSchemaVersion = "trace.correlation/v1" + +const hawkSessionMetadataKey = "hawk_session_id" + +type traceGraphExport struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt time.Time `json:"generated_at"` + Scope graphcontracts.Scope `json:"scope"` + Nodes []graphcontracts.Node `json:"nodes"` + Edges []graphcontracts.Edge `json:"edges"` + Events []graphcontracts.Event `json:"events"` +} + +type traceCorrelationExport struct { + SchemaVersion string `json:"schema_version"` + HawkSessionID string `json:"hawk_session_id"` + CheckpointLookupComplete bool `json:"checkpoint_lookup_complete"` + Matches []traceCorrelationMatch `json:"matches"` +} + +type traceCorrelationMatch struct { + TraceSessionID string `json:"trace_session_id"` + CheckpointIDs []string `json:"checkpoint_ids"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Phase string `json:"phase,omitempty"` +} + +func newGraphCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "graph", + Short: "Export Trace execution data as a portable graph", + Long: `Project Trace sessions and checkpoints into the shared Hawk graph contract. + +Trace remains the source of truth for session and checkpoint storage. The graph +command emits a read-only, portable projection for orchestration, analysis, and +visualization.`, + Args: cobra.NoArgs, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + if _, err := paths.WorktreeRoot(cmd.Context()); err != nil { + return fmt.Errorf("not a git repository: %w", err) + } + return nil + }, + } + + cmd.AddCommand(newGraphExportCmd()) + cmd.AddCommand(newGraphCorrelationCmd()) + return cmd +} + +func newGraphCorrelationCmd() *cobra.Command { + var hawkSessionID string + + cmd := &cobra.Command{ + Use: "correlation", + Short: "Resolve authoritative Trace IDs for a Hawk session", + Long: `Resolve exact Hawk-to-Trace identity captured at Trace session start. + +Trace matches only the stored TRACE_TAG_HAWK_SESSION_ID metadata value. It +returns an empty matches array when no authoritative mapping exists and never +infers identity from branches, commits, timestamps, or prompt content.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + hawkSessionID = strings.TrimSpace(hawkSessionID) + if hawkSessionID == "" { + return fmt.Errorf("--hawk-session is required") + } + + ctx := cmd.Context() + states, err := strategy.ListSessionStates(ctx) + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + committed := []checkpoint.CommittedInfo(nil) + checkpointLookupComplete := true + lookup, lookupErr := newExplainCheckpointLookup(ctx) + if lookupErr != nil { + checkpointLookupComplete = false + } else { + committed = lookup.committed + } + + export := buildTraceCorrelationExport(states, committed, hawkSessionID) + export.CheckpointLookupComplete = checkpointLookupComplete + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(export); err != nil { + return fmt.Errorf("encode graph correlation: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVar(&hawkSessionID, "hawk-session", "", "Exact Hawk persisted-session ID") + return cmd +} + +func buildTraceCorrelationExport( + states []*strategy.SessionState, + committed []checkpoint.CommittedInfo, + hawkSessionID string, +) traceCorrelationExport { + hawkSessionID = strings.TrimSpace(hawkSessionID) + export := traceCorrelationExport{ + SchemaVersion: traceCorrelationSchemaVersion, + HawkSessionID: hawkSessionID, + CheckpointLookupComplete: true, + Matches: make([]traceCorrelationMatch, 0), + } + if hawkSessionID == "" { + return export + } + checkpointsBySession := make(map[string]map[string]struct{}) + for _, info := range committed { + checkpointID := strings.TrimSpace(string(info.CheckpointID)) + if checkpointID == "" { + continue + } + for _, traceSessionID := range graphCheckpointSessionIDs(info, "") { + if checkpointsBySession[traceSessionID] == nil { + checkpointsBySession[traceSessionID] = make(map[string]struct{}) + } + checkpointsBySession[traceSessionID][checkpointID] = struct{}{} + } + } + + for _, state := range states { + if state == nil || state.Metadata[hawkSessionMetadataKey] != hawkSessionID { + continue + } + traceSessionID := strings.TrimSpace(state.SessionID) + if traceSessionID == "" { + continue + } + checkpointIDs := make([]string, 0, len(checkpointsBySession[traceSessionID])) + for checkpointID := range checkpointsBySession[traceSessionID] { + checkpointIDs = append(checkpointIDs, checkpointID) + } + sort.Strings(checkpointIDs) + phase := session.PhaseFromString(string(state.Phase)) + if state.EndedAt != nil { + phase = session.PhaseEnded + } + export.Matches = append(export.Matches, traceCorrelationMatch{ + TraceSessionID: traceSessionID, + CheckpointIDs: checkpointIDs, + StartedAt: state.StartedAt.UTC(), + EndedAt: state.EndedAt, + Phase: string(phase), + }) + } + sort.Slice(export.Matches, func(i, j int) bool { + return export.Matches[i].TraceSessionID < export.Matches[j].TraceSessionID + }) + return export +} + +func newGraphExportCmd() *cobra.Command { + var sessionPrefix string + var limit int + + cmd := &cobra.Command{ + Use: "export", + Short: "Export sessions, checkpoints, relationships, and lifecycle events", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if limit < 1 { + return fmt.Errorf("--limit must be greater than zero") + } + + ctx := cmd.Context() + root, err := paths.WorktreeRoot(ctx) + if err != nil { + return fmt.Errorf("resolve repository: %w", err) + } + states, err := strategy.ListSessionStates(ctx) + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + lookup, err := newExplainCheckpointLookup(ctx) + if err != nil { + return fmt.Errorf("list checkpoints for graph export: %w", err) + } + + export, err := buildTraceGraphExport( + states, + lookup.committed, + time.Now().UTC(), + filepath.Base(filepath.Clean(root)), + strings.TrimSpace(sessionPrefix), + limit, + ) + if err != nil { + return err + } + + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + if err := encoder.Encode(export); err != nil { + return fmt.Errorf("encode graph export: %w", err) + } + return nil + }, + } + + cmd.Flags().StringVar(&sessionPrefix, "session", "", "Filter by session ID or prefix") + cmd.Flags().IntVar(&limit, "limit", branchCheckpointsLimit, "Maximum checkpoints to include") + return cmd +} + +func buildTraceGraphExport( + states []*strategy.SessionState, + committed []checkpoint.CommittedInfo, + generatedAt time.Time, + repositoryID string, + sessionPrefix string, + limit int, +) (traceGraphExport, error) { + if generatedAt.IsZero() { + return traceGraphExport{}, fmt.Errorf("build graph export: generated time is required") + } + if limit < 1 { + return traceGraphExport{}, fmt.Errorf("build graph export: limit must be greater than zero") + } + + generatedAt = generatedAt.UTC() + scope := graphcontracts.Scope{RepositoryID: strings.TrimSpace(repositoryID)} + export := traceGraphExport{ + SchemaVersion: traceGraphSchemaVersion, + GeneratedAt: generatedAt, + Scope: scope, + Nodes: make([]graphcontracts.Node, 0), + Edges: make([]graphcontracts.Edge, 0), + Events: make([]graphcontracts.Event, 0), + } + sessionNodes := make(map[string]struct{}) + + for _, state := range states { + if state == nil { + continue + } + sessionID := strings.TrimSpace(state.SessionID) + if !strings.HasPrefix(sessionID, sessionPrefix) { + continue + } + node, events, err := sessionGraphFacts(state, generatedAt, scope) + if err != nil { + return traceGraphExport{}, err + } + export.Nodes = append(export.Nodes, node) + export.Events = append(export.Events, events...) + sessionNodes[sessionID] = struct{}{} + } + + checkpoints := filterGraphCheckpoints(committed, sessionPrefix, limit) + for _, info := range checkpoints { + checkpointNode, checkpointEvent, err := checkpointGraphFacts(info, generatedAt, scope) + if err != nil { + return traceGraphExport{}, err + } + export.Nodes = append(export.Nodes, checkpointNode) + export.Events = append(export.Events, checkpointEvent) + + for _, sessionID := range graphCheckpointSessionIDs(info, sessionPrefix) { + if _, exists := sessionNodes[sessionID]; !exists { + placeholder, err := checkpointOnlySessionNode(sessionID, info, generatedAt, scope) + if err != nil { + return traceGraphExport{}, err + } + export.Nodes = append(export.Nodes, placeholder) + sessionNodes[sessionID] = struct{}{} + } + + edge := graphcontracts.Edge{ + ID: "trace/edge/" + sessionID + "/produced/" + string(info.CheckpointID), + Kind: graphcontracts.EdgeProduced, + From: graphcontracts.Ref{ + Kind: graphcontracts.NodeExecution, + ID: traceSessionNodeID(sessionID), + }, + To: graphcontracts.Ref{ + Kind: graphcontracts.NodeExecution, + ID: traceCheckpointNodeID(string(info.CheckpointID)), + }, + Scope: scope, + CreatedAt: graphFactTime(info.CreatedAt, generatedAt), + Provenance: graphcontracts.Provenance{ + Producer: "trace", + SourceID: string(info.CheckpointID), + }, + } + if err := edge.Validate(); err != nil { + return traceGraphExport{}, fmt.Errorf("build graph edge %q: %w", edge.ID, err) + } + export.Edges = append(export.Edges, edge) + } + } + + sort.Slice(export.Nodes, func(i, j int) bool { return export.Nodes[i].ID < export.Nodes[j].ID }) + sort.Slice(export.Edges, func(i, j int) bool { return export.Edges[i].ID < export.Edges[j].ID }) + sort.Slice(export.Events, func(i, j int) bool { return export.Events[i].ID < export.Events[j].ID }) + return export, nil +} + +func sessionGraphFacts( + state *strategy.SessionState, + generatedAt time.Time, + scope graphcontracts.Scope, +) (graphcontracts.Node, []graphcontracts.Event, error) { + sessionID := strings.TrimSpace(state.SessionID) + createdAt := graphFactTime(state.StartedAt, generatedAt) + phase := session.PhaseFromString(string(state.Phase)) + if state.EndedAt != nil { + phase = session.PhaseEnded + } + + attributes := map[string]string{ + "entity_type": "session", + "status": string(phase), + "checkpoint_count": strconv.Itoa(state.StepCount), + "files_touched": strconv.Itoa(len(state.FilesTouched)), + "fully_condensed": strconv.FormatBool(state.FullyCondensed), + "attached_manually": strconv.FormatBool(state.AttachedManually), + } + addGraphAttribute(attributes, "agent_type", string(state.AgentType)) + addGraphAttribute(attributes, "model_name", state.ModelName) + addGraphAttribute(attributes, "kind", string(state.Kind)) + addGraphAttribute(attributes, "turn_id", state.TurnID) + addGraphAttribute(attributes, "worktree_id", state.WorktreeID) + addGraphAttribute(attributes, "last_checkpoint_id", string(state.LastCheckpointID)) + + node := graphcontracts.Node{ + ID: traceSessionNodeID(sessionID), + Kind: graphcontracts.NodeExecution, + Scope: scope, + CreatedAt: createdAt, + EffectiveAt: createdAt, + Provenance: graphcontracts.Provenance{ + Producer: "trace", + SourceID: sessionID, + Evidence: []graphcontracts.ArtifactRef{{URI: "trace://session/" + sessionID}}, + }, + Attributes: attributes, + } + if err := node.Validate(); err != nil { + return graphcontracts.Node{}, nil, fmt.Errorf("build session graph node %q: %w", sessionID, err) + } + + createdEvent := graphcontracts.Event{ + ID: "trace/event/session/" + sessionID + "/created", + Type: graphcontracts.EventCreated, + Subject: graphcontracts.Ref{Kind: node.Kind, ID: node.ID}, + Scope: scope, + OccurredAt: createdAt, + CorrelationID: sessionID, + IdempotencyKey: "trace/session/" + sessionID + "/created", + Provenance: node.Provenance, + } + if err := createdEvent.Validate(); err != nil { + return graphcontracts.Node{}, nil, fmt.Errorf("build session created event %q: %w", sessionID, err) + } + events := []graphcontracts.Event{createdEvent} + + if state.EndedAt != nil { + endedAt := graphFactTime(*state.EndedAt, generatedAt) + endedEvent := graphcontracts.Event{ + ID: "trace/event/session/" + sessionID + "/ended", + Type: graphcontracts.EventTransitioned, + Subject: graphcontracts.Ref{Kind: node.Kind, ID: node.ID}, + Scope: scope, + OccurredAt: endedAt, + CorrelationID: sessionID, + CausationID: createdEvent.ID, + IdempotencyKey: "trace/session/" + sessionID + "/ended", + Provenance: node.Provenance, + } + if err := endedEvent.Validate(); err != nil { + return graphcontracts.Node{}, nil, fmt.Errorf("build session ended event %q: %w", sessionID, err) + } + events = append(events, endedEvent) + } + + return node, events, nil +} + +func checkpointGraphFacts( + info checkpoint.CommittedInfo, + generatedAt time.Time, + scope graphcontracts.Scope, +) (graphcontracts.Node, graphcontracts.Event, error) { + checkpointID := strings.TrimSpace(string(info.CheckpointID)) + createdAt := graphFactTime(info.CreatedAt, generatedAt) + sessionCount := info.SessionCount + if sessionCount == 0 { + sessionCount = len(graphCheckpointSessionIDs(info, "")) + } + attributes := map[string]string{ + "entity_type": "checkpoint", + "checkpoint_count": strconv.Itoa(info.CheckpointsCount), + "session_count": strconv.Itoa(sessionCount), + "files_touched": strconv.Itoa(len(info.FilesTouched)), + "is_task": strconv.FormatBool(info.IsTask), + } + addGraphAttribute(attributes, "agent_type", string(info.Agent)) + addGraphAttribute(attributes, "tool_use_id", info.ToolUseID) + + node := graphcontracts.Node{ + ID: traceCheckpointNodeID(checkpointID), + Kind: graphcontracts.NodeExecution, + Scope: scope, + CreatedAt: createdAt, + EffectiveAt: createdAt, + Provenance: graphcontracts.Provenance{ + Producer: "trace", + SourceID: checkpointID, + Evidence: []graphcontracts.ArtifactRef{{URI: "trace://checkpoint/" + checkpointID}}, + }, + Attributes: attributes, + } + if err := node.Validate(); err != nil { + return graphcontracts.Node{}, graphcontracts.Event{}, fmt.Errorf("build checkpoint graph node %q: %w", checkpointID, err) + } + + event := graphcontracts.Event{ + ID: "trace/event/checkpoint/" + checkpointID + "/created", + Type: graphcontracts.EventCreated, + Subject: graphcontracts.Ref{Kind: node.Kind, ID: node.ID}, + Scope: scope, + OccurredAt: createdAt, + CorrelationID: checkpointID, + IdempotencyKey: "trace/checkpoint/" + checkpointID + "/created", + Provenance: node.Provenance, + } + if err := event.Validate(); err != nil { + return graphcontracts.Node{}, graphcontracts.Event{}, fmt.Errorf("build checkpoint event %q: %w", checkpointID, err) + } + return node, event, nil +} + +func checkpointOnlySessionNode( + sessionID string, + info checkpoint.CommittedInfo, + generatedAt time.Time, + scope graphcontracts.Scope, +) (graphcontracts.Node, error) { + node := graphcontracts.Node{ + ID: traceSessionNodeID(sessionID), + Kind: graphcontracts.NodeExecution, + Scope: scope, + CreatedAt: graphFactTime(info.CreatedAt, generatedAt), + Provenance: graphcontracts.Provenance{ + Producer: "trace", + SourceID: string(info.CheckpointID), + Evidence: []graphcontracts.ArtifactRef{{URI: "trace://checkpoint/" + string(info.CheckpointID)}}, + }, + Attributes: map[string]string{ + "entity_type": "session", + "status": "checkpoint_only", + }, + } + if err := node.Validate(); err != nil { + return graphcontracts.Node{}, fmt.Errorf("build checkpoint-only session node %q: %w", sessionID, err) + } + return node, nil +} + +func filterGraphCheckpoints( + committed []checkpoint.CommittedInfo, + sessionPrefix string, + limit int, +) []checkpoint.CommittedInfo { + filtered := make([]checkpoint.CommittedInfo, 0, len(committed)) + for _, info := range committed { + if sessionPrefix != "" && len(graphCheckpointSessionIDs(info, sessionPrefix)) == 0 { + continue + } + filtered = append(filtered, info) + } + sort.SliceStable(filtered, func(i, j int) bool { + if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) { + return string(filtered[i].CheckpointID) < string(filtered[j].CheckpointID) + } + return filtered[i].CreatedAt.After(filtered[j].CreatedAt) + }) + if len(filtered) > limit { + filtered = filtered[:limit] + } + return filtered +} + +func graphCheckpointSessionIDs(info checkpoint.CommittedInfo, sessionPrefix string) []string { + candidates := info.SessionIDs + if len(candidates) == 0 && info.SessionID != "" { + candidates = []string{info.SessionID} + } + seen := make(map[string]struct{}, len(candidates)) + sessionIDs := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + sessionID := strings.TrimSpace(candidate) + if sessionID == "" || !strings.HasPrefix(sessionID, sessionPrefix) { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + seen[sessionID] = struct{}{} + sessionIDs = append(sessionIDs, sessionID) + } + sort.Strings(sessionIDs) + return sessionIDs +} + +func traceSessionNodeID(sessionID string) string { + return "trace/session/" + sessionID +} + +func traceCheckpointNodeID(checkpointID string) string { + return "trace/checkpoint/" + checkpointID +} + +func graphFactTime(value, fallback time.Time) time.Time { + if value.IsZero() { + return fallback + } + return value.UTC() +} + +func addGraphAttribute(attributes map[string]string, key, value string) { + if value = strings.TrimSpace(value); value != "" { + attributes[key] = value + } +} diff --git a/cli/graph_cmd_test.go b/cli/graph_cmd_test.go new file mode 100644 index 0000000..a90f91d --- /dev/null +++ b/cli/graph_cmd_test.go @@ -0,0 +1,228 @@ +package cli + +import ( + "testing" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" + "github.com/GrayCodeAI/trace/cli/checkpoint" + checkpointid "github.com/GrayCodeAI/trace/cli/checkpoint/id" + "github.com/GrayCodeAI/trace/cli/session" + "github.com/GrayCodeAI/trace/cli/strategy" +) + +func TestBuildTraceGraphExport(t *testing.T) { + t.Parallel() + + startedAt := time.Date(2026, time.July, 25, 10, 0, 0, 0, time.UTC) + endedAt := startedAt.Add(20 * time.Minute) + generatedAt := endedAt.Add(time.Minute) + states := []*strategy.SessionState{{ + SessionID: "session-alpha", + StartedAt: startedAt, + EndedAt: &endedAt, + Phase: session.PhaseEnded, + StepCount: 1, + FilesTouched: []string{"cli/root.go"}, + }} + checkpoints := []checkpoint.CommittedInfo{{ + CheckpointID: checkpointid.CheckpointID("abc123def456"), + SessionID: "session-alpha", + SessionIDs: []string{"session-alpha"}, + CreatedAt: endedAt, + CheckpointsCount: 1, + FilesTouched: []string{"cli/root.go"}, + SessionCount: 1, + }} + + export, err := buildTraceGraphExport(states, checkpoints, generatedAt, "hawk", "", 100) + if err != nil { + t.Fatalf("buildTraceGraphExport() error = %v", err) + } + if export.SchemaVersion != traceGraphSchemaVersion { + t.Fatalf("SchemaVersion = %q, want %q", export.SchemaVersion, traceGraphSchemaVersion) + } + if export.Scope.RepositoryID != "hawk" { + t.Fatalf("Scope.RepositoryID = %q, want hawk", export.Scope.RepositoryID) + } + if len(export.Nodes) != 2 { + t.Fatalf("len(Nodes) = %d, want 2", len(export.Nodes)) + } + if len(export.Edges) != 1 { + t.Fatalf("len(Edges) = %d, want 1", len(export.Edges)) + } + if len(export.Events) != 3 { + t.Fatalf("len(Events) = %d, want 3", len(export.Events)) + } + if export.Edges[0].Kind != graphcontracts.EdgeProduced { + t.Fatalf("Edges[0].Kind = %q, want %q", export.Edges[0].Kind, graphcontracts.EdgeProduced) + } + if export.Edges[0].From.ID != traceSessionNodeID("session-alpha") { + t.Fatalf("Edges[0].From.ID = %q", export.Edges[0].From.ID) + } + if export.Edges[0].To.ID != traceCheckpointNodeID("abc123def456") { + t.Fatalf("Edges[0].To.ID = %q", export.Edges[0].To.ID) + } +} + +func TestBuildTraceGraphExportCreatesCheckpointOnlySessionNode(t *testing.T) { + t.Parallel() + + generatedAt := time.Date(2026, time.July, 25, 11, 0, 0, 0, time.UTC) + checkpoints := []checkpoint.CommittedInfo{{ + CheckpointID: checkpointid.CheckpointID("abc123def456"), + SessionID: "archived-session", + CreatedAt: generatedAt.Add(-time.Hour), + SessionCount: 1, + }} + + export, err := buildTraceGraphExport(nil, checkpoints, generatedAt, "trace", "", 100) + if err != nil { + t.Fatalf("buildTraceGraphExport() error = %v", err) + } + node := findTraceGraphNode(export.Nodes, traceSessionNodeID("archived-session")) + if node == nil { + t.Fatal("checkpoint-only session node was not exported") + } + if got := node.Attributes["status"]; got != "checkpoint_only" { + t.Fatalf("checkpoint-only status = %q, want checkpoint_only", got) + } + if len(export.Edges) != 1 { + t.Fatalf("len(Edges) = %d, want 1", len(export.Edges)) + } +} + +func TestBuildTraceGraphExportFiltersSessionAndLimitsCheckpoints(t *testing.T) { + t.Parallel() + + generatedAt := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC) + states := []*strategy.SessionState{ + {SessionID: "alpha-one", StartedAt: generatedAt.Add(-3 * time.Hour)}, + {SessionID: "beta-one", StartedAt: generatedAt.Add(-3 * time.Hour)}, + } + checkpoints := []checkpoint.CommittedInfo{ + { + CheckpointID: checkpointid.CheckpointID("aaaaaaaaaaaa"), + SessionID: "alpha-one", + CreatedAt: generatedAt.Add(-2 * time.Hour), + }, + { + CheckpointID: checkpointid.CheckpointID("bbbbbbbbbbbb"), + SessionID: "alpha-one", + CreatedAt: generatedAt.Add(-time.Hour), + }, + { + CheckpointID: checkpointid.CheckpointID("cccccccccccc"), + SessionID: "beta-one", + CreatedAt: generatedAt, + }, + } + + export, err := buildTraceGraphExport(states, checkpoints, generatedAt, "trace", "alpha", 1) + if err != nil { + t.Fatalf("buildTraceGraphExport() error = %v", err) + } + if len(export.Nodes) != 2 { + t.Fatalf("len(Nodes) = %d, want one session and one checkpoint", len(export.Nodes)) + } + if findTraceGraphNode(export.Nodes, traceCheckpointNodeID("bbbbbbbbbbbb")) == nil { + t.Fatal("newest matching checkpoint was not exported") + } + if findTraceGraphNode(export.Nodes, traceSessionNodeID("beta-one")) != nil { + t.Fatal("non-matching session was exported") + } +} + +func TestBuildTraceCorrelationExportUsesExactStoredIdentity(t *testing.T) { + t.Parallel() + + startedAt := time.Date(2026, time.July, 25, 13, 0, 0, 0, time.UTC) + endedAt := startedAt.Add(time.Hour) + states := []*strategy.SessionState{ + { + SessionID: "trace-beta", + StartedAt: startedAt.Add(time.Minute), + Metadata: map[string]string{hawkSessionMetadataKey: "hawk-session-1"}, + }, + { + SessionID: "trace-alpha", + StartedAt: startedAt, + EndedAt: &endedAt, + Phase: session.PhaseEnded, + Metadata: map[string]string{hawkSessionMetadataKey: "hawk-session-1"}, + }, + { + SessionID: "trace-prefix-only", + StartedAt: startedAt, + Metadata: map[string]string{hawkSessionMetadataKey: "hawk-session-10"}, + }, + } + checkpoints := []checkpoint.CommittedInfo{ + { + CheckpointID: checkpointid.CheckpointID("bbbbbbbbbbbb"), + SessionIDs: []string{"trace-alpha"}, + }, + { + CheckpointID: checkpointid.CheckpointID("aaaaaaaaaaaa"), + SessionID: "trace-alpha", + }, + { + CheckpointID: checkpointid.CheckpointID("cccccccccccc"), + SessionID: "trace-prefix-only", + }, + } + + export := buildTraceCorrelationExport(states, checkpoints, " hawk-session-1 ") + if export.SchemaVersion != traceCorrelationSchemaVersion { + t.Fatalf("SchemaVersion = %q, want %q", export.SchemaVersion, traceCorrelationSchemaVersion) + } + if !export.CheckpointLookupComplete { + t.Fatal("pure correlation projection should mark supplied checkpoints complete") + } + if export.HawkSessionID != "hawk-session-1" { + t.Fatalf("HawkSessionID = %q", export.HawkSessionID) + } + if len(export.Matches) != 2 { + t.Fatalf("len(Matches) = %d, want 2", len(export.Matches)) + } + if export.Matches[0].TraceSessionID != "trace-alpha" { + t.Fatalf("first match = %q, want trace-alpha", export.Matches[0].TraceSessionID) + } + if got := export.Matches[0].CheckpointIDs; len(got) != 2 || + got[0] != "aaaaaaaaaaaa" || got[1] != "bbbbbbbbbbbb" { + t.Fatalf("CheckpointIDs = %#v", got) + } + if export.Matches[0].EndedAt == nil || export.Matches[0].Phase != string(session.PhaseEnded) { + t.Fatalf("ended match = %#v", export.Matches[0]) + } + if len(export.Matches[1].CheckpointIDs) != 0 { + t.Fatalf("unexpected checkpoints for trace-beta: %#v", export.Matches[1].CheckpointIDs) + } +} + +func TestBuildTraceCorrelationExportReturnsNoSpeculativeMatch(t *testing.T) { + t.Parallel() + + states := []*strategy.SessionState{{ + SessionID: "hawk-session-1", + Metadata: map[string]string{"unrelated": "hawk-session-1"}, + }} + export := buildTraceCorrelationExport(states, nil, "hawk-session-1") + if len(export.Matches) != 0 { + t.Fatalf("len(Matches) = %d, want no inferred match", len(export.Matches)) + } + + empty := buildTraceCorrelationExport(states, nil, "") + if empty.Matches == nil || len(empty.Matches) != 0 { + t.Fatalf("empty lookup matches = %#v, want non-nil empty slice", empty.Matches) + } +} + +func findTraceGraphNode(nodes []graphcontracts.Node, id string) *graphcontracts.Node { + for i := range nodes { + if nodes[i].ID == id { + return &nodes[i] + } + } + return nil +} diff --git a/cli/root.go b/cli/root.go index 70345d9..aac98a7 100644 --- a/cli/root.go +++ b/cli/root.go @@ -97,6 +97,7 @@ func NewRootCmd() *cobra.Command { // Noun groups (canonical homes for subcommands). cmd.AddCommand(newSessionsCmd()) // 'session' (with 'sessions' as Cobra alias) cmd.AddCommand(newCheckpointGroupCmd()) // 'checkpoint' / 'cp' / 'checkpoints' + cmd.AddCommand(newGraphCmd()) // 'graph' (portable execution-graph export) cmd.AddCommand(newAgentGroupCmd()) // 'agent' cmd.AddCommand(newAuthCmd()) // 'auth' cmd.AddCommand(newDoctorCmd()) // 'doctor' (group: trace/logs/bundle) diff --git a/cli/root_test.go b/cli/root_test.go index 3230f65..ee10d47 100644 --- a/cli/root_test.go +++ b/cli/root_test.go @@ -234,6 +234,19 @@ func TestCheckpointSearchIsVisibleButTopLevelSearchIsHidden(t *testing.T) { } } +func TestGraphExportCommandIsVisible(t *testing.T) { + t.Parallel() + + root := NewRootCmd() + graphExport, _, err := root.Find([]string{"graph", "export"}) + if err != nil { + t.Fatalf("find graph export: %v", err) + } + if graphExport.Hidden { + t.Fatal("graph export should be visible in graph help") + } +} + func containsString(values []string, want string) bool { for _, value := range values { if value == want { diff --git a/go.mod b/go.mod index 965ca17..a134739 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( charm.land/glamour/v2 v2.0.0 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3 + github.com/GrayCodeAI/hawk-core-contracts v0.1.8 github.com/betterleaks/betterleaks v1.4.1 github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.24 @@ -35,6 +36,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/GrayCodeAI/hawk-core-contracts => ../hawk-core-contracts + require ( cel.dev/expr v0.25.2 // indirect dario.cat/mergo v1.0.2 // indirect From 7fa2217f2dcf850e1c83f6556f9b302097a0b113 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 13:06:58 +0530 Subject: [PATCH 6/7] feat: add execution journal graph - Add JournalGraph for tracking execution flow - Record transitions, durations, and status - Support JSON serialization and portable GraphSpec conversion --- cli/graphjournal/journal.go | 193 ++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 cli/graphjournal/journal.go diff --git a/cli/graphjournal/journal.go b/cli/graphjournal/journal.go new file mode 100644 index 0000000..692e5bf --- /dev/null +++ b/cli/graphjournal/journal.go @@ -0,0 +1,193 @@ +// Package graphjournal provides graph-based execution journaling for trace. +package graphjournal + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + graphcontracts "github.com/GrayCodeAI/hawk-core-contracts/graph" +) + +// JournalEntry represents a single entry in the execution journal. +type JournalEntry struct { + ID string `json:"id"` + Type string `json:"type"` // "agent", "tool", "function", "transition" + NodeID string `json:"node_id"` + Status string `json:"status"` // "started", "completed", "failed" + Input interface{} `json:"input,omitempty"` + Output interface{} `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Duration time.Duration `json:"duration"` + Timestamp time.Time `json:"timestamp"` + CorrelationID string `json:"correlation_id,omitempty"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// JournalGraph represents an execution journal as a graph. +type JournalGraph struct { + mu sync.RWMutex + ID string `json:"id"` + Name string `json:"name"` + Entries []*JournalEntry `json:"entries"` + Nodes map[string]*JournalNode `json:"nodes"` + Edges []JournalEdge `json:"edges"` +} + +// JournalNode represents a node in the execution journal graph. +type JournalNode struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` + Duration time.Duration `json:"duration"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// JournalEdge represents an edge in the execution journal graph. +type JournalEdge struct { + From string `json:"from"` + To string `json:"to"` + Kind string `json:"kind"` // "calls", "produces", "depends_on" + Weight float64 `json:"weight"` + Attrs map[string]interface{} `json:"attrs,omitempty"` +} + +// NewJournalGraph creates a new journal graph. +func NewJournalGraph(id, name string) *JournalGraph { + return &JournalGraph{ + ID: id, + Name: name, + Entries: []*JournalEntry{}, + Nodes: make(map[string]*JournalNode), + Edges: []JournalEdge{}, + } +} + +// AddEntry adds a journal entry. +func (g *JournalGraph) AddEntry(entry *JournalEntry) { + g.mu.Lock() + defer g.mu.Unlock() + g.Entries = append(g.Entries, entry) + + // Create or update node + if node, ok := g.Nodes[entry.NodeID]; ok { + node.Status = entry.Status + if entry.Type == "transition" { + node.CompletedAt = entry.Timestamp + node.Duration = entry.Duration + } + } else { + node := &JournalNode{ + ID: entry.NodeID, + Type: entry.Type, + Name: entry.NodeID, + Status: entry.Status, + StartedAt: entry.Timestamp, + Attrs: entry.Attrs, + } + if entry.Type == "transition" { + node.CompletedAt = entry.Timestamp + node.Duration = entry.Duration + } + g.Nodes[entry.NodeID] = node + } +} + +// AddNode adds a node to the journal graph. +func (g *JournalGraph) AddNode(node *JournalNode) { + g.mu.Lock() + defer g.mu.Unlock() + g.Nodes[node.ID] = node +} + +// AddEdge adds an edge to the journal graph. +func (g *JournalGraph) AddEdge(edge JournalEdge) { + g.mu.Lock() + defer g.mu.Unlock() + g.Edges = append(g.Edges, edge) +} + +// GetNode retrieves a node by ID. +func (g *JournalGraph) GetNode(id string) (*JournalNode, bool) { + g.mu.RLock() + defer g.mu.RUnlock() + node, ok := g.Nodes[id] + return node, ok +} + +// GetNodes returns all nodes. +func (g *JournalGraph) GetNodes() []*JournalNode { + g.mu.RLock() + defer g.mu.RUnlock() + result := make([]*JournalNode, 0, len(g.Nodes)) + for _, node := range g.Nodes { + result = append(result, node) + } + return result +} + +// GetEdges returns all edges. +func (g *JournalGraph) GetEdges() []JournalEdge { + g.mu.RLock() + defer g.mu.RUnlock() + return g.Edges +} + +// ToJSON serializes the journal graph to JSON. +func (g *JournalGraph) ToJSON() ([]byte, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return json.Marshal(g) +} + +// FromJSON deserializes a journal graph from JSON. +func FromJSON(data []byte) (*JournalGraph, error) { + var g JournalGraph + if err := json.Unmarshal(data, &g); err != nil { + return nil, fmt.Errorf("failed to deserialize journal graph: %w", err) + } + return &g, nil +} + +// ToGraphSpec converts the journal graph to a portable graph spec. +func (g *JournalGraph) ToGraphSpec() *graphcontracts.GraphSpec { + g.mu.RLock() + defer g.mu.RUnlock() + + nodes := make([]graphcontracts.NodeSpec, 0, len(g.Nodes)) + for id, node := range g.Nodes { + config := map[string]string{ + "type": node.Type, + "name": node.Name, + "status": node.Status, + "duration": fmt.Sprintf("%v", node.Duration), + } + + nodes = append(nodes, graphcontracts.NodeSpec{ + ID: id, + Type: graphcontracts.NodeExecution, + Name: node.Name, + Config: config, + }) + } + + edges := make([]graphcontracts.EdgeSpec, 0, len(g.Edges)) + for _, edge := range g.Edges { + edges = append(edges, graphcontracts.EdgeSpec{ + From: edge.From, + To: edge.To, + Weight: edge.Weight, + }) + } + + return &graphcontracts.GraphSpec{ + ID: g.ID, + Name: g.Name, + Nodes: nodes, + Edges: edges, + } +} From 447350ae5d414d8792db352c98e03edb58029c3e Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sat, 25 Jul 2026 20:18:25 +0530 Subject: [PATCH 7/7] fix: use NodeTypeExecution instead of NodeExecution (NodeKind) --- cli/graphjournal/journal.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/graphjournal/journal.go b/cli/graphjournal/journal.go index 692e5bf..474bedc 100644 --- a/cli/graphjournal/journal.go +++ b/cli/graphjournal/journal.go @@ -169,7 +169,7 @@ func (g *JournalGraph) ToGraphSpec() *graphcontracts.GraphSpec { nodes = append(nodes, graphcontracts.NodeSpec{ ID: id, - Type: graphcontracts.NodeExecution, + Type: graphcontracts.NodeTypeExecution, Name: node.Name, Config: config, })