From 34dce65f94ef048ba8c81eab6d87cd1a2a478181 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 09:31:50 +0100 Subject: [PATCH 01/24] Remove trail enable/disable commands and related functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trails are now always active — the ability to disable them via `entire trail enable`/`entire trail disable` commands and the `strategy_options.trails` setting has been removed. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 5ba1abd32f4d --- CLAUDE.md | 40 ++ cmd/entire/cli/lifecycle.go | 27 ++ cmd/entire/cli/strategy/push_common.go | 7 + cmd/entire/cli/trail_cmd.go | 489 +++++++++++++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 cmd/entire/cli/trail_cmd.go diff --git a/CLAUDE.md b/CLAUDE.md index 7ffdba5c0..2b15ced48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -344,6 +344,46 @@ All strategies implement: - `temporary.go` - Shadow branch operations (`WriteTemporary`, `ReadTemporary`, `ListTemporary`) - `committed.go` - Metadata branch operations (`WriteCommitted`, `ReadCommitted`, `ListCommitted`) +#### Trail Package (`cmd/entire/cli/trail/`) +- `trail.go` - Types (`ID`, `Status`, `Metadata`, `Discussion`, `Comment`, `CheckpointRef`, `Checkpoints`) and `HumanizeBranchName()` helper +- `store.go` - `Store` struct with CRUD operations on the `entire/trails` orphan branch + +**Command:** `entire trail` — manage trails for branches +- `entire trail` — show current branch's trail, or list all +- `entire trail list` — list all trails (`--status`, `--json` flags) +- `entire trail create` — create a trail (interactive or via `--title`, `--branch`, `--status` flags) +- `entire trail update` — update trail metadata (`--status`, `--title`, `--add-label`, `--remove-label` flags) + +**Auto-create:** On session TurnStart, if on a non-main branch without a trail, one is auto-created with `in_progress` status. The trail title is derived from the user's first prompt (first line, max 80 chars); falls back to `HumanizeBranchName()` if no prompt is available. + +**Checkpoint linking:** After each condensation (manual-commit PostCommit) or save (auto-commit SaveStep), the checkpoint is appended to the trail's `checkpoints.json` (newest first). This is best-effort and non-blocking. + +**Push:** Pre-push hooks push `entire/trails` branch alongside `entire/checkpoints/v1`. + +**Storage:** `entire/trails` orphan branch with sharded paths: +``` +// +├── metadata.json # Trail metadata (title, branch, status, etc.) +├── discussion.json # Comments and replies +└── checkpoints.json # Checkpoint references (newest first) +``` + +**checkpoints.json format:** +```json +{ + "checkpoints": [ + { + "checkpoint_id": "a3b2c4d5e6f7", + "commit_sha": "abc123...", + "created_at": "2026-01-13T12:00:00Z", + "summary": "First line of the user prompt" + } + ] +} +``` + +**Status lifecycle:** `draft → open → in_progress → in_review → done → closed` + #### Session Package (`cmd/entire/cli/session/`) - `session.go` - Session data types and interfaces - `state.go` - `StateStore` for managing `.git/entire-sessions/` files diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index fe924f124..8bd0c114b 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -144,6 +144,9 @@ func handleLifecycleTurnStart(ag agent.Agent, event *agent.Event) error { } } + // Auto-create trail for non-main branches + autoCreateTrailOnTurnStart(event.Prompt) + return nil } @@ -758,6 +761,30 @@ func markSessionEnded(sessionID string) error { return nil } +// autoCreateTrailOnTurnStart creates a trail if on a non-main branch without one. +// Non-blocking: logs errors but doesn't fail the session. +// prompt is the user's prompt text, used for the trail title. +func autoCreateTrailOnTurnStart(prompt string) { + isDefault, branchName, err := IsOnDefaultBranch() + if err != nil || isDefault || branchName == "" { + return + } + + repo, err := strategy.OpenRepository() + if err != nil { + return + } + + baseBranch := strategy.GetDefaultBranchName(repo) + if baseBranch == "" { + baseBranch = defaultBaseBranch + } + + if err := AutoCreateTrail(repo, branchName, baseBranch, prompt); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to auto-create trail: %v\n", err) + } +} + // logFileChanges logs the files modified, created, and deleted during a session. func logFileChanges(modified, newFiles, deleted []string) { fmt.Fprintf(os.Stderr, "Files modified during session (%d):\n", len(modified)) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 48b531665..af7d78784 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/go-git/go-git/v5" @@ -202,6 +203,12 @@ func fetchAndMergeSessionsCommon(remote, branchName string) error { return nil } +// PushTrailsBranch pushes the entire/trails branch to the remote. +// Reuses the same push/sync logic as session branches. +func PushTrailsBranch(remote string) error { + return pushSessionsBranchCommon(remote, paths.TrailsBranchName) +} + // createMergeCommitCommon creates a merge commit with multiple parents. func createMergeCommitCommon(repo *git.Repository, treeHash plumbing.Hash, parents []plumbing.Hash, message string) (plumbing.Hash, error) { authorName, authorEmail := GetGitAuthorFromRepo(repo) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go new file mode 100644 index 000000000..0f97614a9 --- /dev/null +++ b/cmd/entire/cli/trail_cmd.go @@ -0,0 +1,489 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/trail" + + "github.com/charmbracelet/huh" + "github.com/go-git/go-git/v5" + "github.com/spf13/cobra" +) + +func newTrailCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "trail", + Short: "Manage trails for your branches", + Long: `Trails are branch-centric work tracking abstractions. They describe the +"why" and "what" of your work, while checkpoints capture the "how" and "when". + +Running 'entire trail' without a subcommand shows the trail for the current +branch, or lists all trails if no trail exists for the current branch.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailShow(cmd.OutOrStdout()) + }, + } + + cmd.AddCommand(newTrailListCmd()) + cmd.AddCommand(newTrailCreateCmd()) + cmd.AddCommand(newTrailUpdateCmd()) + + return cmd +} + +// runTrailShow shows the trail for the current branch, or falls through to list. +func runTrailShow(w io.Writer) error { + branch, err := GetCurrentBranch() + if err != nil { + return runTrailListAll(w, "", false) + } + + repo, err := strategy.OpenRepository() + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + store := trail.NewStore(repo) + metadata, err := store.FindByBranch(branch) + if err != nil || metadata == nil { + return runTrailListAll(w, "", false) + } + + printTrailDetails(w, metadata) + return nil +} + +func printTrailDetails(w io.Writer, m *trail.Metadata) { + fmt.Fprintf(w, "Trail: %s\n", m.Title) + fmt.Fprintf(w, " ID: %s\n", m.TrailID) + fmt.Fprintf(w, " Branch: %s\n", m.Branch) + fmt.Fprintf(w, " Base: %s\n", m.Base) + fmt.Fprintf(w, " Status: %s\n", m.Status) + fmt.Fprintf(w, " Author: %s\n", m.Author) + if m.Description != "" { + fmt.Fprintf(w, " Description: %s\n", m.Description) + } + if len(m.Labels) > 0 { + fmt.Fprintf(w, " Labels: %s\n", strings.Join(m.Labels, ", ")) + } + if len(m.Assignees) > 0 { + fmt.Fprintf(w, " Assignees: %s\n", strings.Join(m.Assignees, ", ")) + } + fmt.Fprintf(w, " Created: %s\n", m.CreatedAt.Format(time.RFC3339)) + fmt.Fprintf(w, " Updated: %s\n", m.UpdatedAt.Format(time.RFC3339)) +} + +func newTrailListCmd() *cobra.Command { + var statusFilter string + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List all trails", + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailListAll(cmd.OutOrStdout(), statusFilter, jsonOutput) + }, + } + + cmd.Flags().StringVar(&statusFilter, "status", "", "Filter by status (draft, open, in_progress, in_review, done, closed)") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func runTrailListAll(w io.Writer, statusFilter string, jsonOutput bool) error { + repo, err := strategy.OpenRepository() + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + store := trail.NewStore(repo) + trails, err := store.List() + if err != nil { + return fmt.Errorf("failed to list trails: %w", err) + } + + if trails == nil { + trails = []*trail.Metadata{} + } + + // Apply status filter + if statusFilter != "" { + status := trail.Status(statusFilter) + if !status.IsValid() { + return fmt.Errorf("invalid status %q: valid values are %s", statusFilter, formatValidStatuses()) + } + var filtered []*trail.Metadata + for _, t := range trails { + if t.Status == status { + filtered = append(filtered, t) + } + } + trails = filtered + } + + // Sort by updated_at descending + sort.Slice(trails, func(i, j int) bool { + return trails[i].UpdatedAt.After(trails[j].UpdatedAt) + }) + + if jsonOutput { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(trails); err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + return nil + } + + if len(trails) == 0 { + fmt.Fprintln(w, "No trails found.") + fmt.Fprintln(w) + fmt.Fprintln(w, "Commands:") + fmt.Fprintln(w, " entire trail create Create a trail for the current branch") + fmt.Fprintln(w, " entire trail list List all trails") + fmt.Fprintln(w, " entire trail update Update trail metadata") + return nil + } + + // Table output + fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", "BRANCH", "TITLE", "STATUS", "AUTHOR", "UPDATED") + for _, t := range trails { + branch := truncate(t.Branch, 30) + title := truncate(t.Title, 40) + fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", + branch, title, t.Status, truncate(t.Author, 15), timeAgo(t.UpdatedAt)) + } + + return nil +} + +func newTrailCreateCmd() *cobra.Command { + var title, description, base, branch, status string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a trail for the current or a new branch", + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailCreate(cmd.OutOrStdout(), cmd.ErrOrStderr(), title, description, base, branch, status) + }, + } + + cmd.Flags().StringVar(&title, "title", "", "Trail title") + cmd.Flags().StringVar(&description, "description", "", "Trail description") + cmd.Flags().StringVar(&base, "base", "", "Base branch (defaults to detected default branch)") + cmd.Flags().StringVar(&branch, "branch", "", "Branch for the trail (defaults to current branch)") + cmd.Flags().StringVar(&status, "status", "", "Initial status (defaults to draft)") + + return cmd +} + +//nolint:cyclop // sequential steps for creating a trail — splitting would obscure the flow +func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusStr string) error { + repo, err := strategy.OpenRepository() + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + // Determine branch + if branch == "" { + branch, err = GetCurrentBranch() + if err != nil { + return fmt.Errorf("failed to determine current branch: %w", err) + } + } + + // Check if on default branch + if isDefault, _, defaultErr := IsOnDefaultBranch(); defaultErr == nil && isDefault { + fmt.Fprintln(errW, "Cannot create a trail on the default branch.") + fmt.Fprintln(errW, "Create or switch to a feature branch first.") + return NewSilentError(errors.New("cannot create trail on default branch")) + } + + // Determine base branch + if base == "" { + base = strategy.GetDefaultBranchName(repo) + if base == "" { + base = defaultBaseBranch + } + } + + // Check if trail already exists for this branch + store := trail.NewStore(repo) + existing, err := store.FindByBranch(branch) + if err == nil && existing != nil { + fmt.Fprintf(w, "Trail already exists for branch %q (ID: %s)\n", branch, existing.TrailID) + return nil + } + + // Determine title + if title == "" { + defaultTitle := trail.HumanizeBranchName(branch) + + // Interactive mode if flags not provided + if !hasFlag("title") { + var inputTitle string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Trail title"). + Placeholder(defaultTitle). + Value(&inputTitle), + ), + ) + if formErr := form.Run(); formErr != nil { + return fmt.Errorf("form cancelled: %w", formErr) + } + if inputTitle != "" { + title = inputTitle + } else { + title = defaultTitle + } + } else { + title = defaultTitle + } + } + + // Determine status + var trailStatus trail.Status + if statusStr != "" { + trailStatus = trail.Status(statusStr) + if !trailStatus.IsValid() { + return fmt.Errorf("invalid status %q: valid values are %s", statusStr, formatValidStatuses()) + } + } else { + trailStatus = trail.StatusDraft + } + + // Generate trail ID + trailID, err := trail.GenerateID() + if err != nil { + return fmt.Errorf("failed to generate trail ID: %w", err) + } + + // Get git author + authorName, _ := checkpoint.GetGitAuthorFromRepo(repo) + + now := time.Now() + metadata := &trail.Metadata{ + TrailID: trailID, + Branch: branch, + Base: base, + Title: title, + Description: description, + Status: trailStatus, + Author: authorName, + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + + if err := store.Write(metadata, nil, nil); err != nil { + return fmt.Errorf("failed to create trail: %w", err) + } + + fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", title, branch, trailID) + return nil +} + +func newTrailUpdateCmd() *cobra.Command { + var statusStr, title, description, branch string + var labelAdd, labelRemove []string + + cmd := &cobra.Command{ + Use: "update", + Short: "Update trail metadata", + RunE: func(cmd *cobra.Command, _ []string) error { + return runTrailUpdate(cmd.OutOrStdout(), statusStr, title, description, branch, labelAdd, labelRemove) + }, + } + + cmd.Flags().StringVar(&statusStr, "status", "", "Update status") + cmd.Flags().StringVar(&title, "title", "", "Update title") + cmd.Flags().StringVar(&description, "description", "", "Update description") + cmd.Flags().StringVar(&branch, "branch", "", "Branch to update trail for (defaults to current)") + cmd.Flags().StringSliceVar(&labelAdd, "add-label", nil, "Add label(s)") + cmd.Flags().StringSliceVar(&labelRemove, "remove-label", nil, "Remove label(s)") + + return cmd +} + +func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, labelAdd, labelRemove []string) error { + repo, err := strategy.OpenRepository() + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + // Determine branch + if branch == "" { + branch, err = GetCurrentBranch() + if err != nil { + return fmt.Errorf("failed to determine current branch: %w", err) + } + } + + store := trail.NewStore(repo) + metadata, err := store.FindByBranch(branch) + if err != nil { + return fmt.Errorf("failed to find trail: %w", err) + } + if metadata == nil { + return fmt.Errorf("no trail found for branch %q", branch) + } + + // Validate status if provided + if statusStr != "" { + status := trail.Status(statusStr) + if !status.IsValid() { + return fmt.Errorf("invalid status %q: valid values are %s", statusStr, formatValidStatuses()) + } + } + + err = store.Update(metadata.TrailID, func(m *trail.Metadata) { + if statusStr != "" { + m.Status = trail.Status(statusStr) + } + if title != "" { + m.Title = title + } + if description != "" { + m.Description = description + } + for _, l := range labelAdd { + if !containsString(m.Labels, l) { + m.Labels = append(m.Labels, l) + } + } + for _, l := range labelRemove { + m.Labels = removeString(m.Labels, l) + } + }) + if err != nil { + return fmt.Errorf("failed to update trail: %w", err) + } + + fmt.Fprintf(w, "Updated trail for branch %s\n", branch) + return nil +} + +// defaultBaseBranch is the fallback base branch name when it cannot be determined. +const defaultBaseBranch = "main" + +// AutoCreateTrail creates a trail automatically for the current branch if one doesn't exist. +// This is called during session start for non-main branches. +// If prompt is non-empty, its first line is used as the trail title instead of the branch name. +func AutoCreateTrail(repo *git.Repository, branchName, baseBranch, prompt string) error { + store := trail.NewStore(repo) + + existing, err := store.FindByBranch(branchName) + if err == nil && existing != nil { + return nil // Trail already exists + } + + trailID, err := trail.GenerateID() + if err != nil { + return fmt.Errorf("failed to generate trail ID: %w", err) + } + + authorName, _ := checkpoint.GetGitAuthorFromRepo(repo) + now := time.Now() + + title := titleFromPrompt(prompt) + if title == "" { + title = trail.HumanizeBranchName(branchName) + } + + metadata := &trail.Metadata{ + TrailID: trailID, + Branch: branchName, + Base: baseBranch, + Title: title, + Status: trail.StatusInProgress, + Author: authorName, + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + + if err := store.Write(metadata, nil, nil); err != nil { + return fmt.Errorf("failed to create trail: %w", err) + } + + fmt.Fprintf(os.Stderr, "[entire] Created trail for branch: %s\n", branchName) + return nil +} + +// titleFromPrompt extracts a trail title from the user's prompt. +// Uses the first line, trimmed and truncated to 80 characters. +// Returns empty string if prompt is empty. +func titleFromPrompt(prompt string) string { + if prompt == "" { + return "" + } + line, _, _ := strings.Cut(prompt, "\n") + title := strings.TrimSpace(line) + if len(title) > 80 { + title = title[:77] + "..." + } + return title +} + +// hasFlag is a simple helper that checks os.Args for --flag presence. +// Used to distinguish between "flag not provided" and "flag provided with empty value". +func hasFlag(name string) bool { + for _, arg := range os.Args { + if arg == "--"+name || strings.HasPrefix(arg, "--"+name+"=") { + return true + } + } + return false +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +func formatValidStatuses() string { + statuses := trail.ValidStatuses() + names := make([]string, len(statuses)) + for i, s := range statuses { + names[i] = string(s) + } + return strings.Join(names, ", ") +} + +func containsString(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} + +func removeString(slice []string, s string) []string { + var result []string + for _, v := range slice { + if v != s { + result = append(result, v) + } + } + return result +} From 567a6ce9e29960eae895f5884cdd9dd54856538c Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 12:18:43 +0100 Subject: [PATCH 02/24] Add interactive mode to `entire trail update` When run without flags, presents a form with status (select), title (input), and description (text area) pre-filled with current values. Excludes "done" and "closed" from the status select since those are set by merge and permissions respectively. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 65a3b4ef75ef --- cmd/entire/cli/trail_cmd.go | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 0f97614a9..a81c54f07 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -341,6 +341,45 @@ func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, l return fmt.Errorf("no trail found for branch %q", branch) } + // Interactive mode when no flags are provided + noFlags := statusStr == "" && title == "" && description == "" && labelAdd == nil && labelRemove == nil + if noFlags { + // Build status options with current value as default + // Exclude "done" (set on merge) and "closed" (requires permissions) + var statusOptions []huh.Option[string] + for _, s := range trail.ValidStatuses() { + if s == trail.StatusDone || s == trail.StatusClosed { + continue + } + label := string(s) + if s == metadata.Status { + label += " (current)" + } + statusOptions = append(statusOptions, huh.NewOption(label, string(s))) + } + statusStr = string(metadata.Status) + title = metadata.Title + description = metadata.Description + + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Status"). + Options(statusOptions...). + Value(&statusStr), + huh.NewInput(). + Title("Title"). + Value(&title), + huh.NewText(). + Title("Description"). + Value(&description), + ), + ) + if formErr := form.Run(); formErr != nil { + return fmt.Errorf("form cancelled: %w", formErr) + } + } + // Validate status if provided if statusStr != "" { status := trail.Status(statusStr) From 52998e4edea8faf3c00dce4ea7dd35070d2a5dbf Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 12:19:53 +0100 Subject: [PATCH 03/24] Add trail package and register trail command Introduces the trail store (CRUD on entire/trails orphan branch), trail types, and registers `entire trail` in the root command. Updates IsShadowBranch and integration tests to recognize the trails branch. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 87bbe8c4adc7 --- .../deferred_finalization_test.go | 10 +- .../mid_session_commit_test.go | 2 +- cmd/entire/cli/paths/paths.go | 4 + cmd/entire/cli/root.go | 1 + cmd/entire/cli/strategy/cleanup.go | 4 +- cmd/entire/cli/trail/store.go | 434 ++++++++++++++++++ cmd/entire/cli/trail/store_test.go | 333 ++++++++++++++ cmd/entire/cli/trail/trail.go | 182 ++++++++ cmd/entire/cli/trail/trail_test.go | 172 +++++++ 9 files changed, 1134 insertions(+), 8 deletions(-) create mode 100644 cmd/entire/cli/trail/store.go create mode 100644 cmd/entire/cli/trail/store_test.go create mode 100644 cmd/entire/cli/trail/trail.go create mode 100644 cmd/entire/cli/trail/trail_test.go diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go index 21cb65a4a..9bedbfc1e 100644 --- a/cmd/entire/cli/integration_test/deferred_finalization_test.go +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -369,7 +369,7 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { // indicate a regression in carry-forward cleanup. branchesAfterAll := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfterAll { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } @@ -584,7 +584,7 @@ func TestShadow_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { // No shadow branches should remain after all files are committed branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } @@ -1191,7 +1191,7 @@ func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) { // No shadow branches should remain branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } @@ -1286,7 +1286,7 @@ func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) { // doesn't produce full metadata (known limitation). branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Logf("Shadow branch remaining after commits (may be expected for deleted files): %s", b) } } @@ -1381,7 +1381,7 @@ func TestShadow_CarryForward_ModifiedExistingFiles(t *testing.T) { // No shadow branches should remain branchesAfter := env.ListBranchesWithPrefix("entire/") for _, b := range branchesAfter { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { t.Errorf("Unexpected shadow branch after all files committed: %s", b) } } diff --git a/cmd/entire/cli/integration_test/mid_session_commit_test.go b/cmd/entire/cli/integration_test/mid_session_commit_test.go index c8147e8d1..ef8659248 100644 --- a/cmd/entire/cli/integration_test/mid_session_commit_test.go +++ b/cmd/entire/cli/integration_test/mid_session_commit_test.go @@ -71,7 +71,7 @@ func TestShadowStrategy_MidSessionCommit_FromTranscript(t *testing.T) { shadowBranches := env.ListBranchesWithPrefix("entire/") hasShadowBranch := false for _, b := range shadowBranches { - if b != paths.MetadataBranchName { + if b != paths.MetadataBranchName && b != paths.TrailsBranchName { hasShadowBranch = true break } diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 641e420bc..dc4542734 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -37,6 +37,10 @@ const ( // MetadataBranchName is the orphan branch used by auto-commit and manual-commit strategies to store metadata const MetadataBranchName = "entire/checkpoints/v1" +// TrailsBranchName is the orphan branch used to store trail metadata. +// Trails are branch-centric work tracking abstractions that link to checkpoints by branch name. +const TrailsBranchName = "entire/trails" + // CheckpointPath returns the sharded storage path for a checkpoint ID. // Uses first 2 characters as shard (256 buckets), remaining as folder name. // Example: "a3b2c4d5e6f7" -> "a3/b2c4d5e6f7" diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5fedf6ad4..9bdedfdaa 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -83,6 +83,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newExplainCmd()) cmd.AddCommand(newDebugCmd()) cmd.AddCommand(newDoctorCmd()) + cmd.AddCommand(newTrailCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index 937b82804..f53e95e7e 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -64,8 +64,8 @@ var shadowBranchPattern = regexp.MustCompile(`^entire/[0-9a-fA-F]{7,}(-[0-9a-fA- // commit hash is at least 7 hex characters and worktree hash is 6 hex characters. // The "entire/checkpoints/v1" branch is NOT a shadow branch. func IsShadowBranch(branchName string) bool { - // Explicitly exclude entire/checkpoints/v1 - if branchName == paths.MetadataBranchName { + // Explicitly exclude metadata and trails branches + if branchName == paths.MetadataBranchName || branchName == paths.TrailsBranchName { return false } return shadowBranchPattern.MatchString(branchName) diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go new file mode 100644 index 000000000..8416c22b8 --- /dev/null +++ b/cmd/entire/cli/trail/store.go @@ -0,0 +1,434 @@ +package trail + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/object" +) + +const ( + metadataFile = "metadata.json" + discussionFile = "discussion.json" + checkpointsFile = "checkpoints.json" +) + +// ErrTrailNotFound is returned when a trail cannot be found. +var ErrTrailNotFound = errors.New("trail not found") + +// Store provides CRUD operations for trail metadata on the entire/trails branch. +type Store struct { + repo *git.Repository +} + +// NewStore creates a new trail store backed by the given git repository. +func NewStore(repo *git.Repository) *Store { + return &Store{repo: repo} +} + +// EnsureBranch creates the entire/trails orphan branch if it doesn't exist. +func (s *Store) EnsureBranch() error { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + _, err := s.repo.Reference(refName, true) + if err == nil { + return nil // Branch already exists + } + + // Create orphan branch with empty tree + emptyTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + if err != nil { + return fmt.Errorf("failed to build empty tree: %w", err) + } + + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) + commitHash, err := s.createCommit(emptyTreeHash, plumbing.ZeroHash, "Initialize trails branch", authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create initial commit: %w", err) + } + + newRef := plumbing.NewHashReference(refName, commitHash) + if err := s.repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to set branch reference: %w", err) + } + return nil +} + +// Write writes trail metadata, discussion, and checkpoints to the entire/trails branch. +// If checkpoints is nil, an empty checkpoints list is written. +func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error { + if metadata.TrailID.IsEmpty() { + return errors.New("trail ID is required") + } + + if err := s.EnsureBranch(); err != nil { + return fmt.Errorf("failed to ensure trails branch: %w", err) + } + + // Get current branch tree + ref, entries, err := s.getBranchEntries() + if err != nil { + return fmt.Errorf("failed to get branch entries: %w", err) + } + + // Build sharded path + basePath := metadata.TrailID.Path() + "/" + + // Create metadata blob + metadataJSON, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + metadataBlob, err := checkpoint.CreateBlobFromContent(s.repo, metadataJSON) + if err != nil { + return fmt.Errorf("failed to create metadata blob: %w", err) + } + entries[basePath+metadataFile] = object.TreeEntry{ + Name: basePath + metadataFile, + Mode: filemode.Regular, + Hash: metadataBlob, + } + + // Create discussion blob + if discussion == nil { + discussion = &Discussion{Comments: []Comment{}} + } + discussionJSON, err := json.MarshalIndent(discussion, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal discussion: %w", err) + } + discussionBlob, err := checkpoint.CreateBlobFromContent(s.repo, discussionJSON) + if err != nil { + return fmt.Errorf("failed to create discussion blob: %w", err) + } + entries[basePath+discussionFile] = object.TreeEntry{ + Name: basePath + discussionFile, + Mode: filemode.Regular, + Hash: discussionBlob, + } + + // Create checkpoints blob + if checkpoints == nil { + checkpoints = &Checkpoints{Checkpoints: []CheckpointRef{}} + } + checkpointsJSON, err := json.MarshalIndent(checkpoints, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal checkpoints: %w", err) + } + checkpointsBlob, err := checkpoint.CreateBlobFromContent(s.repo, checkpointsJSON) + if err != nil { + return fmt.Errorf("failed to create checkpoints blob: %w", err) + } + entries[basePath+checkpointsFile] = object.TreeEntry{ + Name: basePath + checkpointsFile, + Mode: filemode.Regular, + Hash: checkpointsBlob, + } + + // Build tree and commit + newTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, entries) + if err != nil { + return fmt.Errorf("failed to build tree: %w", err) + } + + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Trail: %s (%s)", metadata.Title, metadata.TrailID) + commitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + // Update branch ref + newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.TrailsBranchName), commitHash) + if err := s.repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to update branch reference: %w", err) + } + + return nil +} + +// Read reads a trail by its ID from the entire/trails branch. +func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { + tree, err := s.getBranchTree() + if err != nil { + return nil, nil, nil, err + } + + basePath := trailID.Path() + "/" + + // Read metadata + metadataEntry, err := tree.FindEntry(basePath + metadataFile) + if err != nil { + return nil, nil, nil, fmt.Errorf("trail %s not found: %w", trailID, err) + } + metadataBlob, err := s.repo.BlobObject(metadataEntry.Hash) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to read metadata blob: %w", err) + } + metadataReader, err := metadataBlob.Reader() + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to open metadata reader: %w", err) + } + defer metadataReader.Close() + + var metadata Metadata + if err := json.NewDecoder(metadataReader).Decode(&metadata); err != nil { + return nil, nil, nil, fmt.Errorf("failed to decode metadata: %w", err) + } + + // Read discussion (optional, may not exist yet) + var discussion Discussion + discussionEntry, err := tree.FindEntry(basePath + discussionFile) + if err == nil { + discussionBlob, blobErr := s.repo.BlobObject(discussionEntry.Hash) + if blobErr == nil { + discussionReader, readerErr := discussionBlob.Reader() + if readerErr == nil { + //nolint:errcheck,gosec // best-effort decode of optional discussion + json.NewDecoder(discussionReader).Decode(&discussion) + _ = discussionReader.Close() + } + } + } + + // Read checkpoints (optional, may not exist yet) + var checkpoints Checkpoints + checkpointsEntry, err := tree.FindEntry(basePath + checkpointsFile) + if err == nil { + checkpointsBlob, blobErr := s.repo.BlobObject(checkpointsEntry.Hash) + if blobErr == nil { + checkpointsReader, readerErr := checkpointsBlob.Reader() + if readerErr == nil { + //nolint:errcheck,gosec // best-effort decode of optional checkpoints + json.NewDecoder(checkpointsReader).Decode(&checkpoints) + _ = checkpointsReader.Close() + } + } + } + + return &metadata, &discussion, &checkpoints, nil +} + +// FindByBranch finds a trail for the given branch name. +// Returns nil, nil, nil if no trail exists for the branch. +func (s *Store) FindByBranch(branchName string) (*Metadata, error) { + trails, err := s.List() + if err != nil { + return nil, err + } + + for _, t := range trails { + if t.Branch == branchName { + return t, nil + } + } + return nil, nil //nolint:nilnil // nil, nil means "not found" — callers check both +} + +// List returns all trail metadata from the entire/trails branch. +func (s *Store) List() ([]*Metadata, error) { + tree, err := s.getBranchTree() + if err != nil { + // Branch doesn't exist yet — no trails + return nil, nil //nolint:nilerr // Expected when no trails exist yet + } + + var trails []*Metadata + entries := make(map[string]object.TreeEntry) + if err := checkpoint.FlattenTree(s.repo, tree, "", entries); err != nil { + return nil, fmt.Errorf("failed to flatten tree: %w", err) + } + + // Find all metadata.json files + for path, entry := range entries { + if !strings.HasSuffix(path, "/"+metadataFile) { + continue + } + + blob, err := s.repo.BlobObject(entry.Hash) + if err != nil { + continue + } + reader, err := blob.Reader() + if err != nil { + continue + } + + var metadata Metadata + decodeErr := json.NewDecoder(reader).Decode(&metadata) + _ = reader.Close() + if decodeErr != nil { + continue + } + + trails = append(trails, &metadata) + } + + return trails, nil +} + +// Update updates an existing trail's metadata. It reads the current metadata, +// applies the provided update function, and writes it back. +func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { + metadata, discussion, checkpoints, err := s.Read(trailID) + if err != nil { + return fmt.Errorf("failed to read trail for update: %w", err) + } + + updateFn(metadata) + metadata.UpdatedAt = time.Now() + + return s.Write(metadata, discussion, checkpoints) +} + +// AddCheckpoint prepends a checkpoint reference to a trail's checkpoints list (newest first). +func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { + metadata, discussion, checkpoints, err := s.Read(trailID) + if err != nil { + return fmt.Errorf("failed to read trail for checkpoint update: %w", err) + } + + if checkpoints == nil { + checkpoints = &Checkpoints{Checkpoints: []CheckpointRef{}} + } + + // Prepend new ref (newest first) + checkpoints.Checkpoints = append([]CheckpointRef{ref}, checkpoints.Checkpoints...) + + return s.Write(metadata, discussion, checkpoints) +} + +// Delete removes a trail from the entire/trails branch. +func (s *Store) Delete(trailID ID) error { + ref, entries, err := s.getBranchEntries() + if err != nil { + return fmt.Errorf("failed to get branch entries: %w", err) + } + + basePath := trailID.Path() + "/" + + // Remove entries for this trail + found := false + for path := range entries { + if strings.HasPrefix(path, basePath) { + delete(entries, path) + found = true + } + } + if !found { + return fmt.Errorf("trail %s not found", trailID) + } + + // Build tree and commit + newTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, entries) + if err != nil { + return fmt.Errorf("failed to build tree: %w", err) + } + + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) + commitMsg := fmt.Sprintf("Delete trail: %s", trailID) + commitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create commit: %w", err) + } + + newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.TrailsBranchName), commitHash) + if err := s.repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to update branch reference: %w", err) + } + + return nil +} + +// getBranchTree returns the tree for the entire/trails branch HEAD. +func (s *Store) getBranchTree() (*object.Tree, error) { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + ref, err := s.repo.Reference(refName, true) + if err != nil { + // Try remote tracking branch + remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.TrailsBranchName) + ref, err = s.repo.Reference(remoteRefName, true) + if err != nil { + return nil, fmt.Errorf("trails branch not found: %w", err) + } + } + + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("failed to get commit: %w", err) + } + + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("failed to get tree: %w", err) + } + + return tree, nil +} + +// getBranchEntries returns the current branch reference and a flat map of all tree entries. +func (s *Store) getBranchEntries() (*plumbing.Reference, map[string]object.TreeEntry, error) { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + ref, err := s.repo.Reference(refName, true) + if err != nil { + return nil, nil, fmt.Errorf("trails branch not found: %w", err) + } + + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + return nil, nil, fmt.Errorf("failed to get commit: %w", err) + } + + tree, err := commit.Tree() + if err != nil { + return nil, nil, fmt.Errorf("failed to get tree: %w", err) + } + + entries := make(map[string]object.TreeEntry) + if err := checkpoint.FlattenTree(s.repo, tree, "", entries); err != nil { + return nil, nil, fmt.Errorf("failed to flatten tree: %w", err) + } + + return ref, entries, nil +} + +// createCommit creates a commit on the trails branch. +func (s *Store) createCommit(treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { + now := time.Now() + sig := object.Signature{ + Name: authorName, + Email: authorEmail, + When: now, + } + + commit := &object.Commit{ + TreeHash: treeHash, + Author: sig, + Committer: sig, + Message: message, + } + + if parentHash != plumbing.ZeroHash { + commit.ParentHashes = []plumbing.Hash{parentHash} + } + + obj := s.repo.Storer.NewEncodedObject() + if err := commit.Encode(obj); err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) + } + + hash, err := s.repo.Storer.SetEncodedObject(obj) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) + } + + return hash, nil +} diff --git a/cmd/entire/cli/trail/store_test.go b/cmd/entire/cli/trail/store_test.go new file mode 100644 index 000000000..abbf46744 --- /dev/null +++ b/cmd/entire/cli/trail/store_test.go @@ -0,0 +1,333 @@ +package trail + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" +) + +// initTestRepo creates a test git repository with an initial commit. +func initTestRepo(t *testing.T) *git.Repository { + t.Helper() + + dir := t.TempDir() + + ctx := context.Background() + cmds := [][]string{ + {"git", "init", dir}, + {"git", "-C", dir, "config", "user.name", "Test"}, + {"git", "-C", dir, "config", "user.email", "test@test.com"}, + } + for _, args := range cmds { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %v failed: %v\n%s", args, err, out) + } + } + + // Create a file and commit + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Test"), 0o644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + commitCmds := [][]string{ + {"git", "-C", dir, "add", "."}, + {"git", "-C", dir, "commit", "-m", "Initial commit"}, + } + for _, args := range commitCmds { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %v failed: %v\n%s", args, err, out) + } + } + + repo, err := git.PlainOpen(dir) + if err != nil { + t.Fatalf("failed to open repo: %v", err) + } + return repo +} + +func TestStore_EnsureBranch(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // First call should create the branch + if err := store.EnsureBranch(); err != nil { + t.Fatalf("EnsureBranch() error = %v", err) + } + + // Second call should be idempotent + if err := store.EnsureBranch(); err != nil { + t.Fatalf("EnsureBranch() second call error = %v", err) + } +} + +func TestStore_WriteAndRead(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + trailID, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + + now := time.Now().Truncate(time.Second) + metadata := &Metadata{ + TrailID: trailID, + Branch: "feature/test", + Base: "main", + Title: "Test trail", + Description: "A test trail", + Status: StatusDraft, + Author: "tester", + Assignees: []string{}, + Labels: []string{"test"}, + CreatedAt: now, + UpdatedAt: now, + } + + discussion := &Discussion{Comments: []Comment{}} + + if err := store.Write(metadata, discussion, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Read it back + gotMeta, gotDisc, _, err := store.Read(trailID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + + if gotMeta.TrailID != trailID { + t.Errorf("Read() trail_id = %s, want %s", gotMeta.TrailID, trailID) + } + if gotMeta.Branch != "feature/test" { + t.Errorf("Read() branch = %q, want %q", gotMeta.Branch, "feature/test") + } + if gotMeta.Title != "Test trail" { + t.Errorf("Read() title = %q, want %q", gotMeta.Title, "Test trail") + } + if gotMeta.Status != StatusDraft { + t.Errorf("Read() status = %q, want %q", gotMeta.Status, StatusDraft) + } + if len(gotMeta.Labels) != 1 || gotMeta.Labels[0] != "test" { + t.Errorf("Read() labels = %v, want [test]", gotMeta.Labels) + } + if gotDisc == nil { + t.Error("Read() discussion should not be nil") + } +} + +func TestStore_FindByBranch(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + now := time.Now() + + // Create two trails for different branches + for _, branch := range []string{"feature/a", "feature/b"} { + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: branch, + Base: "main", + Title: HumanizeBranchName(branch), + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + } + + // Find by branch + found, err := store.FindByBranch("feature/a") + if err != nil { + t.Fatalf("FindByBranch() error = %v", err) + } + if found == nil { + t.Fatal("FindByBranch() returned nil, expected trail") + } + if found.Branch != "feature/a" { + t.Errorf("FindByBranch() branch = %q, want %q", found.Branch, "feature/a") + } + + // Not found + notFound, err := store.FindByBranch("feature/c") + if err != nil { + t.Fatalf("FindByBranch() error = %v", err) + } + if notFound != nil { + t.Error("FindByBranch() should return nil for non-existent branch") + } +} + +func TestStore_List(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // List when no trails exist (branch doesn't exist yet) + trails, err := store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if trails != nil { + t.Errorf("List() = %v, want nil for empty store", trails) + } + + // Create a trail + now := time.Now() + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: "feature/test", + Base: "main", + Title: "Test", + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + trails, err = store.List() + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(trails) != 1 { + t.Fatalf("List() returned %d trails, want 1", len(trails)) + } + if trails[0].TrailID != id { + t.Errorf("List()[0].TrailID = %s, want %s", trails[0].TrailID, id) + } +} + +func TestStore_Update(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + now := time.Now() + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: "feature/test", + Base: "main", + Title: "Original", + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Update + if err := store.Update(id, func(m *Metadata) { + m.Title = "Updated" + m.Status = StatusInProgress + m.Labels = []string{"urgent"} + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + + // Verify + updated, _, _, err := store.Read(id) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if updated.Title != "Updated" { + t.Errorf("Read() title = %q, want %q", updated.Title, "Updated") + } + if updated.Status != StatusInProgress { + t.Errorf("Read() status = %q, want %q", updated.Status, StatusInProgress) + } + if len(updated.Labels) != 1 || updated.Labels[0] != "urgent" { + t.Errorf("Read() labels = %v, want [urgent]", updated.Labels) + } + if !updated.UpdatedAt.After(now) { + t.Error("Read() updated_at should be after original") + } +} + +func TestStore_Delete(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + now := time.Now() + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + meta := &Metadata{ + TrailID: id, + Branch: "feature/test", + Base: "main", + Title: "To delete", + Status: StatusDraft, + Author: "test", + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, + } + if err := store.Write(meta, nil, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Delete + if err := store.Delete(id); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + // Verify it's gone + _, _, _, err = store.Read(id) + if err == nil { + t.Error("Read() should fail after delete") + } +} + +func TestStore_ReadNonExistent(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + if err := store.EnsureBranch(); err != nil { + t.Fatalf("EnsureBranch() error = %v", err) + } + + _, _, _, err := store.Read(ID("abcdef123456")) + if err == nil { + t.Error("Read() should fail for non-existent trail") + } +} diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go new file mode 100644 index 000000000..37bc9e010 --- /dev/null +++ b/cmd/entire/cli/trail/trail.go @@ -0,0 +1,182 @@ +// Package trail provides types and helpers for managing trail metadata. +// Trails are branch-centric work tracking abstractions stored on the +// entire/trails orphan branch. They answer "why/what" (human intent) +// while checkpoints answer "how/when" (machine snapshots). +package trail + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "regexp" + "strings" + "time" +) + +const idLength = 6 // 6 bytes = 12 hex chars + +// ID is a 12-character hex identifier for trails. +type ID string + +// EmptyID represents an unset or invalid trail ID. +const EmptyID ID = "" + +// idRegex validates the format: exactly 12 lowercase hex characters. +var idRegex = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// GenerateID creates a new random 12-character hex trail ID. +func GenerateID() (ID, error) { + bytes := make([]byte, idLength) + if _, err := rand.Read(bytes); err != nil { + return EmptyID, fmt.Errorf("failed to generate random trail ID: %w", err) + } + return ID(hex.EncodeToString(bytes)), nil +} + +// ValidateID checks if a string is a valid trail ID format. +func ValidateID(s string) error { + if !idRegex.MatchString(s) { + return fmt.Errorf("invalid trail ID %q: must be 12 lowercase hex characters", s) + } + return nil +} + +// String returns the trail ID as a string. +func (id ID) String() string { + return string(id) +} + +// IsEmpty returns true if the trail ID is empty or unset. +func (id ID) IsEmpty() bool { + return id == EmptyID +} + +// Path returns the sharded storage path for this trail ID. +// Uses first 2 characters as shard (256 buckets), remaining as folder name. +// Example: "a3b2c4d5e6f7" -> "a3/b2c4d5e6f7" +func (id ID) Path() string { + if len(id) < 3 { + return string(id) + } + return string(id[:2]) + "/" + string(id[2:]) +} + +// Status represents the lifecycle status of a trail. +type Status string + +const ( + StatusDraft Status = "draft" + StatusOpen Status = "open" + StatusInProgress Status = "in_progress" + StatusInReview Status = "in_review" + StatusDone Status = "done" + StatusClosed Status = "closed" +) + +// ValidStatuses returns all valid trail statuses in lifecycle order. +func ValidStatuses() []Status { + return []Status{ + StatusDraft, + StatusOpen, + StatusInProgress, + StatusInReview, + StatusDone, + StatusClosed, + } +} + +// IsValid returns true if the status is a recognized trail status. +func (s Status) IsValid() bool { + for _, vs := range ValidStatuses() { + if s == vs { + return true + } + } + return false +} + +// Metadata represents the metadata for a trail, matching the web PR format. +type Metadata struct { + TrailID ID `json:"trail_id"` + Branch string `json:"branch"` + Base string `json:"base"` + Title string `json:"title"` + Description string `json:"description"` + Status Status `json:"status"` + Author string `json:"author"` + Assignees []string `json:"assignees"` + Labels []string `json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` +} + +// Discussion holds the discussion/comments for a trail. +type Discussion struct { + Comments []Comment `json:"comments"` +} + +// Comment represents a single comment on a trail. +type Comment struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + Replies []CommentReply `json:"replies,omitempty"` +} + +// CommentReply represents a reply to a comment. +type CommentReply struct { + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` +} + +// commonBranchPrefixes are stripped from branch names when humanizing. +var commonBranchPrefixes = []string{ + "feature/", + "fix/", + "bugfix/", + "chore/", + "hotfix/", + "release/", +} + +// CheckpointRef links a checkpoint to a trail. +type CheckpointRef struct { + CheckpointID string `json:"checkpoint_id"` + CommitSHA string `json:"commit_sha"` + CreatedAt time.Time `json:"created_at"` + Summary string `json:"summary,omitempty"` +} + +// Checkpoints holds the list of checkpoint references for a trail. +type Checkpoints struct { + Checkpoints []CheckpointRef `json:"checkpoints"` +} + +// HumanizeBranchName converts a branch name into a human-readable title. +// It strips common prefixes (feature/, fix/, etc.), replaces dashes/underscores +// with spaces, and capitalizes the first word. +func HumanizeBranchName(branch string) string { + name := branch + for _, prefix := range commonBranchPrefixes { + if strings.HasPrefix(name, prefix) { + name = strings.TrimPrefix(name, prefix) + break + } + } + + // Replace - and _ with spaces + name = strings.NewReplacer("-", " ", "_", " ").Replace(name) + + // Trim spaces and capitalize first letter + name = strings.TrimSpace(name) + if name == "" { + return branch + } + + // Capitalize first character + return strings.ToUpper(name[:1]) + name[1:] +} diff --git a/cmd/entire/cli/trail/trail_test.go b/cmd/entire/cli/trail/trail_test.go new file mode 100644 index 000000000..45e0e3ed8 --- /dev/null +++ b/cmd/entire/cli/trail/trail_test.go @@ -0,0 +1,172 @@ +package trail + +import ( + "testing" +) + +func TestGenerateID(t *testing.T) { + t.Parallel() + + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + if len(id) != 12 { + t.Errorf("expected 12-char ID, got %d: %q", len(id), id) + } + if err := ValidateID(id.String()); err != nil { + t.Errorf("generated ID failed validation: %v", err) + } +} + +func TestGenerateID_Unique(t *testing.T) { + t.Parallel() + + seen := make(map[ID]bool) + for range 100 { + id, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + if seen[id] { + t.Errorf("duplicate ID generated: %s", id) + } + seen[id] = true + } +} + +func TestValidateID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + wantErr bool + }{ + {"valid", "abcdef123456", false}, + {"valid_all_hex", "0123456789ab", false}, + {"too_short", "abcdef", true}, + {"too_long", "abcdef1234567", true}, + {"uppercase", "ABCDEF123456", true}, + {"non_hex", "ghijkl123456", true}, + {"empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateID(tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateID(%q) error = %v, wantErr %v", tt.id, err, tt.wantErr) + } + }) + } +} + +func TestID_Path(t *testing.T) { + t.Parallel() + + tests := []struct { + id ID + want string + }{ + {"abcdef123456", "ab/cdef123456"}, + {"0123456789ab", "01/23456789ab"}, + {"ab", "ab"}, + } + + for _, tt := range tests { + t.Run(string(tt.id), func(t *testing.T) { + t.Parallel() + if got := tt.id.Path(); got != tt.want { + t.Errorf("ID(%q).Path() = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestID_IsEmpty(t *testing.T) { + t.Parallel() + + if !EmptyID.IsEmpty() { + t.Error("EmptyID.IsEmpty() should return true") + } + id := ID("abcdef123456") + if id.IsEmpty() { + t.Error("non-empty ID.IsEmpty() should return false") + } +} + +func TestStatus_IsValid(t *testing.T) { + t.Parallel() + + tests := []struct { + status Status + valid bool + }{ + {StatusDraft, true}, + {StatusOpen, true}, + {StatusInProgress, true}, + {StatusInReview, true}, + {StatusDone, true}, + {StatusClosed, true}, + {"invalid", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + t.Parallel() + if got := tt.status.IsValid(); got != tt.valid { + t.Errorf("Status(%q).IsValid() = %v, want %v", tt.status, got, tt.valid) + } + }) + } +} + +func TestValidStatuses(t *testing.T) { + t.Parallel() + + statuses := ValidStatuses() + if len(statuses) != 6 { + t.Errorf("expected 6 statuses, got %d", len(statuses)) + } + // Verify lifecycle order + expected := []Status{StatusDraft, StatusOpen, StatusInProgress, StatusInReview, StatusDone, StatusClosed} + for i, s := range expected { + if statuses[i] != s { + t.Errorf("status[%d] = %q, want %q", i, statuses[i], s) + } + } +} + +func TestHumanizeBranchName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + branch string + want string + }{ + {"feature prefix", "feature/add-auth", "Add auth"}, + {"fix prefix", "fix/login-bug", "Login bug"}, + {"bugfix prefix", "bugfix/typo-fix", "Typo fix"}, + {"chore prefix", "chore/update-deps", "Update deps"}, + {"hotfix prefix", "hotfix/security-patch", "Security patch"}, + {"release prefix", "release/v2.0", "V2.0"}, + {"no prefix", "add-auth", "Add auth"}, + {"underscores", "add_user_auth", "Add user auth"}, + {"mixed separators", "fix/some_complex-name", "Some complex name"}, + {"simple name", "main", "Main"}, + {"empty after prefix", "feature/", "feature/"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := HumanizeBranchName(tt.branch); got != tt.want { + t.Errorf("HumanizeBranchName(%q) = %q, want %q", tt.branch, got, tt.want) + } + }) + } +} From 110ec1879d6347a9756a693f7094c9fdbe43e67d Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 12:20:00 +0100 Subject: [PATCH 04/24] Add checkpoint-trail linking and trail title generation After each condensation (manual-commit) or save (auto-commit), the checkpoint is appended to the trail's checkpoints list. On first condensation, generates an AI-powered trail title and description from the transcript. Both strategies now push the trails branch on pre-push. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 0cbe6f215a22 --- cmd/entire/cli/strategy/auto_commit.go | 17 +++- .../strategy/manual_commit_condensation.go | 2 + .../cli/strategy/manual_commit_hooks.go | 95 +++++++++++++++++++ cmd/entire/cli/strategy/manual_commit_push.go | 5 +- .../cli/strategy/manual_commit_types.go | 4 +- 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go index 5ee5ced3e..5d9d7fabb 100644 --- a/cmd/entire/cli/strategy/auto_commit.go +++ b/cmd/entire/cli/strategy/auto_commit.go @@ -117,7 +117,10 @@ func (s *AutoCommitStrategy) ValidateRepository() error { // - "prompt" (default): ask user with option to enable auto // - "false"/"off"/"no": never push func (s *AutoCommitStrategy) PrePush(remote string) error { - return pushSessionsBranchCommon(remote, paths.MetadataBranchName) + if err := pushSessionsBranchCommon(remote, paths.MetadataBranchName); err != nil { + return err + } + return PushTrailsBranch(remote) } func (s *AutoCommitStrategy) SaveStep(ctx StepContext) error { @@ -159,6 +162,18 @@ func (s *AutoCommitStrategy) SaveStep(ctx StepContext) error { return fmt.Errorf("failed to commit metadata to entire/checkpoints/v1 branch: %w", err) } + // Link checkpoint to trail (best-effort) + appendCheckpointToTrail(repo, cpID, codeResult.CommitHash, nil) + + // Generate trail title/description (best-effort, first condensation only) + branchName := GetCurrentBranchName(repo) + if branchName != "" && branchName != GetDefaultBranchName(repo) && ctx.TranscriptPath != "" { + if transcriptData, readErr := os.ReadFile(ctx.TranscriptPath); readErr == nil { + filesTouched := mergeFilesTouched(nil, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles) + generateTrailTitleFromTranscript(repo, branchName, transcriptData, filesTouched, ctx.AgentType) + } + } + // Log checkpoint creation logCtx := logging.WithComponent(context.Background(), "checkpoint") logging.Info(logCtx, "checkpoint saved", diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 741f64a8c..0526ec123 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -252,7 +252,9 @@ func (s *ManualCommitStrategy) CondenseSession(repo *git.Repository, checkpointI SessionID: state.SessionID, CheckpointsCount: state.StepCount, FilesTouched: sessionData.FilesTouched, + Prompts: sessionData.Prompts, TotalTranscriptLines: sessionData.FullTranscriptLines, + Transcript: sessionData.Transcript, }, nil } diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index efd79cfbb..08b93662e 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" @@ -18,7 +19,10 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/stringutil" + "github.com/entireio/cli/cmd/entire/cli/summarize" + "github.com/entireio/cli/cmd/entire/cli/trail" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/redact" @@ -819,6 +823,15 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( return false } + // Link checkpoint to trail (best-effort) + appendCheckpointToTrail(repo, result.CheckpointID, head.Hash(), result.Prompts) + + // Generate trail title/description from transcript (best-effort, first condensation only) + branchName := GetCurrentBranchName(repo) + if branchName != "" && branchName != GetDefaultBranchName(repo) && len(result.Transcript) > 0 { + generateTrailTitleFromTranscript(repo, branchName, result.Transcript, result.FilesTouched, state.AgentType) + } + // Track this shadow branch for cleanup shadowBranchesToDelete[shadowBranchName] = struct{}{} @@ -1907,3 +1920,85 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( slog.Int("remaining_files", len(remainingFiles)), ) } + +// generateTrailTitleFromTranscript uses the agent's text generation capability +// to generate a proper title and description for the trail. Best-effort: silently +// returns on any error. Only generates once (skips if description already set). +func generateTrailTitleFromTranscript(repo *git.Repository, branchName string, transcriptBytes []byte, filesTouched []string, agentType agent.AgentType) { + if !settings.IsSummarizeEnabled() { + return + } + store := trail.NewStore(repo) + existing, err := store.FindByBranch(branchName) + if err != nil || existing == nil { + return + } + // Only generate once: skip if description already set + if existing.Description != "" { + return + } + + logCtx := logging.WithComponent(context.Background(), "trail-title") + result, err := summarize.GenerateTrailTitle(logCtx, transcriptBytes, filesTouched, agentType) + if err != nil { + logging.Debug(logCtx, "trail title generation skipped", + slog.String("error", err.Error())) + return + } + + //nolint:errcheck,gosec // best-effort: trail title generation is non-critical + store.Update(existing.TrailID, func(m *trail.Metadata) { + if result.Title != "" { + m.Title = result.Title + } + if result.Description != "" { + m.Description = result.Description + } + }) +} + +// appendCheckpointToTrail links a checkpoint to the trail for the current branch. +// Best-effort: silently returns on any error (trails are non-critical metadata). +func appendCheckpointToTrail(repo *git.Repository, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { + branchName := GetCurrentBranchName(repo) + if branchName == "" { + return + } + defaultBranch := GetDefaultBranchName(repo) + if branchName == defaultBranch { + return + } + + store := trail.NewStore(repo) + existing, err := store.FindByBranch(branchName) + if err != nil || existing == nil { + return + } + + var summary string + if len(prompts) > 0 { + summary = truncateForSummary(prompts[len(prompts)-1], 200) + } + + //nolint:errcheck,gosec // best-effort: trail checkpoint linking is non-critical + store.AddCheckpoint(existing.TrailID, trail.CheckpointRef{ + CheckpointID: cpID.String(), + CommitSHA: commitSHA.String(), + CreatedAt: time.Now().UTC(), + Summary: summary, + }) +} + +// truncateForSummary truncates a string to maxLen, adding "..." if truncated. +// Takes the first line only to keep summaries concise. +func truncateForSummary(s string, maxLen int) string { + line, _, _ := strings.Cut(s, "\n") + line = strings.TrimSpace(line) + if len(line) <= maxLen { + return line + } + if maxLen <= 3 { + return line[:maxLen] + } + return line[:maxLen-3] + "..." +} diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index 9694d238a..310040fc1 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -9,5 +9,8 @@ import "github.com/entireio/cli/cmd/entire/cli/paths" // - "prompt" (default): ask user with option to enable auto // - "false"/"off"/"no": never push func (s *ManualCommitStrategy) PrePush(remote string) error { - return pushSessionsBranchCommon(remote, paths.MetadataBranchName) + if err := pushSessionsBranchCommon(remote, paths.MetadataBranchName); err != nil { + return err + } + return PushTrailsBranch(remote) } diff --git a/cmd/entire/cli/strategy/manual_commit_types.go b/cmd/entire/cli/strategy/manual_commit_types.go index d7d6ad58e..c4e9562ad 100644 --- a/cmd/entire/cli/strategy/manual_commit_types.go +++ b/cmd/entire/cli/strategy/manual_commit_types.go @@ -51,7 +51,9 @@ type CondenseResult struct { SessionID string CheckpointsCount int FilesTouched []string - TotalTranscriptLines int // Total lines in transcript after this condensation + Prompts []string // User prompts from the condensed session + TotalTranscriptLines int // Total lines in transcript after this condensation + Transcript []byte // Raw transcript bytes for downstream consumers (trail title generation) } // ExtractedSessionData contains data extracted from a shadow branch. From ab7673e1e74db36a0dd7b2ffe3a9385e1e394fed Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 12:20:06 +0100 Subject: [PATCH 05/24] Add TextGenerator agent interface and trail title summarizer Adds TextGenerator interface for agents supporting non-interactive text generation (e.g., claude --print). Implements it for Claude Code and adds summarize.GenerateTrailTitle for AI-powered trail title/description generation from transcripts. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f96123e0ece4 --- cmd/entire/cli/agent/agent.go | 12 +++ cmd/entire/cli/agent/claudecode/generate.go | 72 ++++++++++++++++++ cmd/entire/cli/summarize/trail_title.go | 83 +++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 cmd/entire/cli/agent/claudecode/generate.go create mode 100644 cmd/entire/cli/summarize/trail_title.go diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 68473284b..3616ce2aa 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -4,6 +4,7 @@ package agent import ( + "context" "io" ) @@ -169,6 +170,17 @@ type TokenCalculator interface { CalculateTokenUsage(sessionRef string, fromOffset int) (*TokenUsage, error) } +// TextGenerator is an optional interface for agents whose CLI supports +// non-interactive text generation (e.g., claude --print). +// Used for AI-powered metadata generation (trail titles, summaries). +type TextGenerator interface { + Agent + + // GenerateText sends a prompt to the agent's CLI and returns the raw text response. + // model is a hint (e.g., "haiku", "sonnet"). Implementations may ignore if not applicable. + GenerateText(ctx context.Context, prompt string, model string) (string, error) +} + // SubagentAwareExtractor provides methods for extracting files and tokens including subagents. // Agents that support spawning subagents (like Claude Code's Task tool) should implement this // to ensure subagent contributions are included in checkpoints. diff --git a/cmd/entire/cli/agent/claudecode/generate.go b/cmd/entire/cli/agent/claudecode/generate.go new file mode 100644 index 000000000..8533375c8 --- /dev/null +++ b/cmd/entire/cli/agent/claudecode/generate.go @@ -0,0 +1,72 @@ +package claudecode + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// GenerateText sends a prompt to the Claude CLI and returns the raw text response. +// Implements the agent.TextGenerator interface. +// The model parameter hints which model to use (e.g., "haiku", "sonnet"). +// If empty, defaults to "haiku" for fast, cheap generation. +func (c *ClaudeCodeAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + claudePath := "claude" + if model == "" { + model = "haiku" + } + + cmd := exec.CommandContext(ctx, claudePath, + "--print", "--output-format", "json", + "--model", model, "--setting-sources", "") + + // Isolate from the user's git repo to prevent recursive hook triggers + // and index pollution (same approach as summarize/claude.go). + cmd.Dir = os.TempDir() + cmd.Env = stripGitEnv(os.Environ()) + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("claude CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run claude CLI: %w", err) + } + + // Parse the {"result": "..."} envelope + var response struct { + Result string `json:"result"` + } + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + return "", fmt.Errorf("failed to parse claude CLI response: %w", err) + } + + return response.Result, nil +} + +// stripGitEnv returns a copy of env with all GIT_* variables removed. +// This prevents a subprocess from discovering or modifying the parent's git repo. +// Duplicated from summarize/claude.go — simple filter not worth extracting to shared package. +func stripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/summarize/trail_title.go b/cmd/entire/cli/summarize/trail_title.go new file mode 100644 index 000000000..e7b381670 --- /dev/null +++ b/cmd/entire/cli/summarize/trail_title.go @@ -0,0 +1,83 @@ +package summarize + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// trailTitlePromptTemplate is the prompt used to generate trail titles and descriptions. +// +// Security note: The transcript is wrapped in tags to provide clear boundary +// markers. This helps contain any potentially malicious content within the transcript. +const trailTitlePromptTemplate = `Analyze this development session transcript and generate a title and description. + + +%s + + +Return a JSON object: +{ + "title": "Short imperative title (max 80 chars)", + "description": "1-3 sentence description of what was accomplished and why" +} + +Guidelines: +- Title: imperative mood, captures core intent (e.g. "Add user authentication flow") +- Description: explain the "what" and "why", not the "how" +- Return ONLY the JSON object` + +// trailTitleModel is the model hint for trail title generation. +// Haiku is fast (~1-2s) and cheap — trail titles are simple tasks. +const trailTitleModel = "haiku" + +// TrailTitleResult contains the LLM-generated title and description for a trail. +type TrailTitleResult struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// GenerateTrailTitle generates a title and description for a trail using the agent's +// text generation capability. Returns nil if the agent doesn't support text generation. +func GenerateTrailTitle(ctx context.Context, transcriptBytes []byte, filesTouched []string, agentType agent.AgentType) (*TrailTitleResult, error) { + // Get the active agent and check if it implements TextGenerator + ag, err := agent.GetByAgentType(agentType) + if err != nil { + return nil, fmt.Errorf("agent not found: %w", err) + } + gen, ok := ag.(agent.TextGenerator) + if !ok { + return nil, fmt.Errorf("agent %s does not support text generation", agentType) + } + + // Build condensed transcript (reuse existing infrastructure) + condensed, err := BuildCondensedTranscriptFromBytes(transcriptBytes, agentType) + if err != nil { + return nil, fmt.Errorf("failed to parse transcript: %w", err) + } + if len(condensed) == 0 { + return nil, errors.New("transcript has no content") + } + + input := Input{Transcript: condensed, FilesTouched: filesTouched} + transcriptText := FormatCondensedTranscript(input) + + // Build prompt and call agent's TextGenerator + prompt := fmt.Sprintf(trailTitlePromptTemplate, transcriptText) + rawResult, err := gen.GenerateText(ctx, prompt, trailTitleModel) + if err != nil { + return nil, fmt.Errorf("text generation failed: %w", err) + } + + // Parse JSON response (handle markdown code blocks) + cleaned := extractJSONFromMarkdown(rawResult) + var result TrailTitleResult + if err := json.Unmarshal([]byte(cleaned), &result); err != nil { + return nil, fmt.Errorf("failed to parse trail title JSON: %w (response: %s)", err, cleaned) + } + + return &result, nil +} From c6ff70263c77b746701e344528d0881b1110138d Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 12:20:12 +0100 Subject: [PATCH 06/24] Update local dev settings with wingman and telemetry Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 710851ce13d8 --- .entire/settings.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.entire/settings.json b/.entire/settings.json index 885cc30a8..59b841f19 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -1,5 +1,11 @@ { "strategy": "manual-commit", "enabled": true, - "local_dev": true + "local_dev": true, + "strategy_options": { + "wingman": { + "enabled": true + } + }, + "telemetry": true } From 3e5b0fbb7b69bac52834e57fb8c59eead9674c9d Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 14:35:17 +0100 Subject: [PATCH 07/24] Create branch when creating a trail `entire trail create` now creates the git branch if it doesn't exist. When on the default branch without --branch, prompts for a branch name. The branch is created without checkout by default. Use --checkout flag or answer the interactive prompt to switch to the new branch after creation. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: fc76ba4ab962 --- cmd/entire/cli/trail_cmd.go | 104 ++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index a81c54f07..59571b9a0 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/huh" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/spf13/cobra" ) @@ -169,12 +170,13 @@ func runTrailListAll(w io.Writer, statusFilter string, jsonOutput bool) error { func newTrailCreateCmd() *cobra.Command { var title, description, base, branch, status string + var checkout bool cmd := &cobra.Command{ Use: "create", Short: "Create a trail for the current or a new branch", RunE: func(cmd *cobra.Command, _ []string) error { - return runTrailCreate(cmd.OutOrStdout(), cmd.ErrOrStderr(), title, description, base, branch, status) + return runTrailCreate(cmd.OutOrStdout(), cmd.ErrOrStderr(), title, description, base, branch, status, checkout) }, } @@ -183,32 +185,18 @@ func newTrailCreateCmd() *cobra.Command { cmd.Flags().StringVar(&base, "base", "", "Base branch (defaults to detected default branch)") cmd.Flags().StringVar(&branch, "branch", "", "Branch for the trail (defaults to current branch)") cmd.Flags().StringVar(&status, "status", "", "Initial status (defaults to draft)") + cmd.Flags().BoolVar(&checkout, "checkout", false, "Check out the branch after creating it") return cmd } //nolint:cyclop // sequential steps for creating a trail — splitting would obscure the flow -func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusStr string) error { +func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusStr string, checkout bool) error { repo, err := strategy.OpenRepository() if err != nil { return fmt.Errorf("failed to open repository: %w", err) } - // Determine branch - if branch == "" { - branch, err = GetCurrentBranch() - if err != nil { - return fmt.Errorf("failed to determine current branch: %w", err) - } - } - - // Check if on default branch - if isDefault, _, defaultErr := IsOnDefaultBranch(); defaultErr == nil && isDefault { - fmt.Fprintln(errW, "Cannot create a trail on the default branch.") - fmt.Fprintln(errW, "Create or switch to a feature branch first.") - return NewSilentError(errors.New("cannot create trail on default branch")) - } - // Determine base branch if base == "" { base = strategy.GetDefaultBranchName(repo) @@ -217,6 +205,44 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS } } + // Determine branch — create and checkout if needed + isDefault, currentBranch, _ := IsOnDefaultBranch() //nolint:errcheck // best-effort detection + if branch == "" { + if !isDefault { + branch = currentBranch + } else { + // On default branch with no --branch flag: prompt for a new branch name + var inputBranch string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Branch name"). + Placeholder("feat/my-feature"). + Value(&inputBranch), + ), + ) + if formErr := form.Run(); formErr != nil { + return fmt.Errorf("form cancelled: %w", formErr) + } + inputBranch = strings.TrimSpace(inputBranch) + if inputBranch == "" { + return errors.New("branch name is required") + } + branch = inputBranch + } + } + + // Create the branch if it doesn't exist + needsCreation := branchNeedsCreation(repo, branch) + if needsCreation { + if err := createBranch(repo, branch); err != nil { + return fmt.Errorf("failed to create branch %q: %w", branch, err) + } + fmt.Fprintf(w, "Created branch %s\n", branch) + } else if currentBranch != branch { + fmt.Fprintf(errW, "Note: trail will be created for branch %q (not the current branch)\n", branch) + } + // Check if trail already exists for this branch store := trail.NewStore(repo) existing, err := store.FindByBranch(branch) @@ -293,6 +319,31 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS } fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", title, branch, trailID) + + // Checkout the branch if requested or prompted + if needsCreation && currentBranch != branch { + shouldCheckout := checkout + if !shouldCheckout && !hasFlag("checkout") { + // Interactive: ask whether to checkout + form := NewAccessibleForm( + huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Check out branch %s?", branch)). + Value(&shouldCheckout), + ), + ) + if formErr := form.Run(); formErr == nil && shouldCheckout { + checkout = true + } + } + if checkout { + if err := CheckoutBranch(branch); err != nil { + return fmt.Errorf("failed to checkout branch %q: %w", branch, err) + } + fmt.Fprintf(w, "Switched to branch %s\n", branch) + } + } + return nil } @@ -526,3 +577,22 @@ func removeString(slice []string, s string) []string { } return result } + +// branchNeedsCreation checks if a branch exists locally. +func branchNeedsCreation(repo *git.Repository, branchName string) bool { + _, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true) + return err != nil +} + +// createBranch creates a new local branch pointing at HEAD without checking it out. +func createBranch(repo *git.Repository, branchName string) error { + head, err := repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), head.Hash()) + if err := repo.Storer.SetReference(ref); err != nil { + return fmt.Errorf("failed to create branch ref: %w", err) + } + return nil +} From 7c07499e6a9582e9a13f13b368456bbf05f333c2 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 15:25:12 +0100 Subject: [PATCH 08/24] Always prompt for branch name in interactive trail create Previously, `trail create` on a non-default branch would default to the current branch and short-circuit if a trail already existed. Now it always prompts for a branch name (with current branch as placeholder), allowing users to create trails for new branches from any branch. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 0c1e138e4ae0 --- cmd/entire/cli/trail_cmd.go | 45 ++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 59571b9a0..72305b389 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -205,31 +205,36 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS } } - // Determine branch — create and checkout if needed - isDefault, currentBranch, _ := IsOnDefaultBranch() //nolint:errcheck // best-effort detection + // Determine branch + _, currentBranch, _ := IsOnDefaultBranch() //nolint:errcheck // best-effort detection if branch == "" { - if !isDefault { + // Interactive: prompt for branch name with current branch as placeholder + placeholder := currentBranch + if placeholder == "" { + placeholder = "feat/my-feature" + } + var inputBranch string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Branch name"). + Placeholder(placeholder). + Value(&inputBranch), + ), + ) + if formErr := form.Run(); formErr != nil { + return fmt.Errorf("form cancelled: %w", formErr) + } + inputBranch = strings.TrimSpace(inputBranch) + if inputBranch == "" { + // Use current branch if user just pressed enter branch = currentBranch } else { - // On default branch with no --branch flag: prompt for a new branch name - var inputBranch string - form := NewAccessibleForm( - huh.NewGroup( - huh.NewInput(). - Title("Branch name"). - Placeholder("feat/my-feature"). - Value(&inputBranch), - ), - ) - if formErr := form.Run(); formErr != nil { - return fmt.Errorf("form cancelled: %w", formErr) - } - inputBranch = strings.TrimSpace(inputBranch) - if inputBranch == "" { - return errors.New("branch name is required") - } branch = inputBranch } + if branch == "" { + return errors.New("branch name is required") + } } // Create the branch if it doesn't exist From 4b17b6740257c12046ea4563a30ecef383297b59 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 15:28:27 +0100 Subject: [PATCH 09/24] Prompt for title first in interactive trail create The interactive flow now asks for the trail title first, then derives a branch name from it (slugified). The user can accept the suggested branch or type a different one. Non-interactive `--title` without `--branch` also derives the branch automatically. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: ff63e394e0c2 --- cmd/entire/cli/trail_cmd.go | 112 ++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 44 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 72305b389..40e58fc9c 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -205,38 +205,69 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS } } - // Determine branch _, currentBranch, _ := IsOnDefaultBranch() //nolint:errcheck // best-effort detection - if branch == "" { - // Interactive: prompt for branch name with current branch as placeholder - placeholder := currentBranch - if placeholder == "" { - placeholder = "feat/my-feature" - } - var inputBranch string + interactive := !hasFlag("title") && !hasFlag("branch") + + // Step 1: Determine title (interactive: prompt first, derive branch from it) + if title == "" && interactive { + var inputTitle string form := NewAccessibleForm( huh.NewGroup( huh.NewInput(). - Title("Branch name"). - Placeholder(placeholder). - Value(&inputBranch), + Title("Trail title"). + Placeholder("What are you working on?"). + Value(&inputTitle), ), ) if formErr := form.Run(); formErr != nil { return fmt.Errorf("form cancelled: %w", formErr) } - inputBranch = strings.TrimSpace(inputBranch) - if inputBranch == "" { - // Use current branch if user just pressed enter + title = strings.TrimSpace(inputTitle) + if title == "" { + return errors.New("trail title is required") + } + } + + // Step 2: Determine branch + if branch == "" { + switch { + case interactive: + // Derive branch name from title, let user override + suggested := slugifyTitle(title) + var inputBranch string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Branch name"). + Placeholder(suggested). + Value(&inputBranch), + ), + ) + if formErr := form.Run(); formErr != nil { + return fmt.Errorf("form cancelled: %w", formErr) + } + inputBranch = strings.TrimSpace(inputBranch) + if inputBranch != "" { + branch = inputBranch + } else { + branch = suggested + } + case hasFlag("title"): + // --title provided without --branch: derive branch from title + branch = slugifyTitle(title) + default: branch = currentBranch - } else { - branch = inputBranch } if branch == "" { return errors.New("branch name is required") } } + // If title still empty (non-interactive with --branch only), derive from branch + if title == "" { + title = trail.HumanizeBranchName(branch) + } + // Create the branch if it doesn't exist needsCreation := branchNeedsCreation(repo, branch) if needsCreation { @@ -256,34 +287,6 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS return nil } - // Determine title - if title == "" { - defaultTitle := trail.HumanizeBranchName(branch) - - // Interactive mode if flags not provided - if !hasFlag("title") { - var inputTitle string - form := NewAccessibleForm( - huh.NewGroup( - huh.NewInput(). - Title("Trail title"). - Placeholder(defaultTitle). - Value(&inputTitle), - ), - ) - if formErr := form.Run(); formErr != nil { - return fmt.Errorf("form cancelled: %w", formErr) - } - if inputTitle != "" { - title = inputTitle - } else { - title = defaultTitle - } - } else { - title = defaultTitle - } - } - // Determine status var trailStatus trail.Status if statusStr != "" { @@ -583,6 +586,27 @@ func removeString(slice []string, s string) []string { return result } +// slugifyTitle converts a title string into a branch-friendly slug. +// Example: "Add user authentication" -> "add-user-authentication" +func slugifyTitle(title string) string { + s := strings.ToLower(strings.TrimSpace(title)) + // Replace spaces and underscores with hyphens + s = strings.NewReplacer(" ", "-", "_", "-").Replace(s) + // Remove anything that's not alphanumeric, hyphen, or slash + var b strings.Builder + prevHyphen := false + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '/' { + b.WriteRune(r) + prevHyphen = false + } else if r == '-' && !prevHyphen { + b.WriteRune('-') + prevHyphen = true + } + } + return strings.Trim(b.String(), "-") +} + // branchNeedsCreation checks if a branch exists locally. func branchNeedsCreation(repo *git.Repository, branchName string) bool { _, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true) From 2d2b83d6c1e9da47d282f269c9ddb2c023e0c8ad Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 15:32:11 +0100 Subject: [PATCH 10/24] Add description and status to interactive trail create The interactive flow now prompts for all fields: 1. Title + description 2. Branch (derived from title) + status (select) 3. Checkout confirmation (if new branch created) Excludes "done" and "closed" from the status select. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 73308b5a6ad8 --- cmd/entire/cli/trail_cmd.go | 127 +++++++++++++++++++++--------------- 1 file changed, 75 insertions(+), 52 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 40e58fc9c..89e4d876d 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -208,64 +208,26 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS _, currentBranch, _ := IsOnDefaultBranch() //nolint:errcheck // best-effort detection interactive := !hasFlag("title") && !hasFlag("branch") - // Step 1: Determine title (interactive: prompt first, derive branch from it) - if title == "" && interactive { - var inputTitle string - form := NewAccessibleForm( - huh.NewGroup( - huh.NewInput(). - Title("Trail title"). - Placeholder("What are you working on?"). - Value(&inputTitle), - ), - ) - if formErr := form.Run(); formErr != nil { - return fmt.Errorf("form cancelled: %w", formErr) + if interactive { + // Interactive flow: title → description → branch (derived) → status + if err := runTrailCreateInteractive(&title, &description, &branch, &statusStr); err != nil { + return err } - title = strings.TrimSpace(inputTitle) - if title == "" { - return errors.New("trail title is required") - } - } - - // Step 2: Determine branch - if branch == "" { - switch { - case interactive: - // Derive branch name from title, let user override - suggested := slugifyTitle(title) - var inputBranch string - form := NewAccessibleForm( - huh.NewGroup( - huh.NewInput(). - Title("Branch name"). - Placeholder(suggested). - Value(&inputBranch), - ), - ) - if formErr := form.Run(); formErr != nil { - return fmt.Errorf("form cancelled: %w", formErr) - } - inputBranch = strings.TrimSpace(inputBranch) - if inputBranch != "" { - branch = inputBranch + } else { + // Non-interactive: derive missing values from provided flags + if branch == "" { + if hasFlag("title") { + branch = slugifyTitle(title) } else { - branch = suggested + branch = currentBranch } - case hasFlag("title"): - // --title provided without --branch: derive branch from title - branch = slugifyTitle(title) - default: - branch = currentBranch } - if branch == "" { - return errors.New("branch name is required") + if title == "" { + title = trail.HumanizeBranchName(branch) } } - - // If title still empty (non-interactive with --branch only), derive from branch - if title == "" { - title = trail.HumanizeBranchName(branch) + if branch == "" { + return errors.New("branch name is required") } // Create the branch if it doesn't exist @@ -586,6 +548,67 @@ func removeString(slice []string, s string) []string { return result } +// runTrailCreateInteractive runs the interactive form for trail creation. +// Prompts for title, description, branch (derived from title), and status. +func runTrailCreateInteractive(title, description, branch, statusStr *string) error { + // Step 1: Title and description + form := NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Trail title"). + Placeholder("What are you working on?"). + Value(title), + huh.NewText(). + Title("Description (optional)"). + Value(description), + ), + ) + if err := form.Run(); err != nil { + return fmt.Errorf("form cancelled: %w", err) + } + *title = strings.TrimSpace(*title) + if *title == "" { + return errors.New("trail title is required") + } + + // Step 2: Branch (derived from title) and status + suggested := slugifyTitle(*title) + *branch = suggested + + // Build status options, excluding done/closed + var statusOptions []huh.Option[string] + for _, s := range trail.ValidStatuses() { + if s == trail.StatusDone || s == trail.StatusClosed { + continue + } + statusOptions = append(statusOptions, huh.NewOption(string(s), string(s))) + } + if *statusStr == "" { + *statusStr = string(trail.StatusDraft) + } + + form = NewAccessibleForm( + huh.NewGroup( + huh.NewInput(). + Title("Branch name"). + Placeholder(suggested). + Value(branch), + huh.NewSelect[string](). + Title("Status"). + Options(statusOptions...). + Value(statusStr), + ), + ) + if err := form.Run(); err != nil { + return fmt.Errorf("form cancelled: %w", err) + } + *branch = strings.TrimSpace(*branch) + if *branch == "" { + *branch = suggested + } + return nil +} + // slugifyTitle converts a title string into a branch-friendly slug. // Example: "Add user authentication" -> "add-user-authentication" func slugifyTitle(title string) string { From 6e687f70c07d08a010d74c745e21b1841dd04335 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 15:57:47 +0100 Subject: [PATCH 11/24] Push branch and trail data to origin after trail create After creating a trail, pushes the new branch to origin (with -u for tracking) and syncs the trails metadata branch. Push failures are non-blocking warnings. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 18e798499438 --- cmd/entire/cli/trail_cmd.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 89e4d876d..2d7aec6cf 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -1,11 +1,13 @@ package cli import ( + "context" "encoding/json" "errors" "fmt" "io" "os" + "os/exec" "sort" "strings" "time" @@ -290,6 +292,18 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS fmt.Fprintf(w, "Created trail %q for branch %s (ID: %s)\n", title, branch, trailID) + // Push the branch and trail data to origin + if needsCreation { + if err := pushBranchToOrigin(branch); err != nil { + fmt.Fprintf(errW, "Warning: failed to push branch: %v\n", err) + } else { + fmt.Fprintf(w, "Pushed branch %s to origin\n", branch) + } + } + if err := strategy.PushTrailsBranch("origin"); err != nil { + fmt.Fprintf(errW, "Warning: failed to push trail data: %v\n", err) + } + // Checkout the branch if requested or prompted if needsCreation && currentBranch != branch { shouldCheckout := checkout @@ -648,3 +662,14 @@ func createBranch(repo *git.Repository, branchName string) error { } return nil } + +// pushBranchToOrigin pushes a branch to the origin remote. +func pushBranchToOrigin(branchName string) error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", "origin", branchName) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} From acc68d977e33a76ca7d4163942dcee58a8125acc Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 16:20:17 +0100 Subject: [PATCH 12/24] Use GitHub username as trail author Trail author is now resolved via `gh api user` to get the GitHub login. Falls back to git user.name if gh CLI is unavailable or not authenticated. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 6dd269f0a910 --- cmd/entire/cli/trail_cmd.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 2d7aec6cf..01ee4e4f1 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trail" @@ -268,8 +267,8 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS return fmt.Errorf("failed to generate trail ID: %w", err) } - // Get git author - authorName, _ := checkpoint.GetGitAuthorFromRepo(repo) + // Get author (GitHub username, falls back to git user.name) + authorName := getTrailAuthor(repo) now := time.Now() metadata := &trail.Metadata{ @@ -469,7 +468,7 @@ func AutoCreateTrail(repo *git.Repository, branchName, baseBranch, prompt string return fmt.Errorf("failed to generate trail ID: %w", err) } - authorName, _ := checkpoint.GetGitAuthorFromRepo(repo) + authorName := getTrailAuthor(repo) now := time.Now() title := titleFromPrompt(prompt) @@ -623,6 +622,21 @@ func runTrailCreateInteractive(title, description, branch, statusStr *string) er return nil } +// getTrailAuthor returns the GitHub username for the trail author. +// Falls back to git user.name if gh CLI is unavailable or not authenticated. +func getTrailAuthor(repo *git.Repository) string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "gh", "api", "user", "-q", ".login") + if output, err := cmd.Output(); err == nil { + if login := strings.TrimSpace(string(output)); login != "" { + return login + } + } + name, _ := strategy.GetGitAuthorFromRepo(repo) + return name +} + // slugifyTitle converts a title string into a branch-friendly slug. // Example: "Add user authentication" -> "add-user-authentication" func slugifyTitle(title string) string { From af464a29a6a79da769df3fda9ac83e4e06c2314b Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 23 Feb 2026 17:36:02 +0100 Subject: [PATCH 13/24] Fetch remote trails before listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `entire trail list` now fetches the remote trails branch before listing, so trails created by collaborators are visible. The fetch is best-effort with a 10s timeout — failures are silent. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: a170e94303e0 --- cmd/entire/cli/trail_cmd.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 01ee4e4f1..2229ae6e5 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trail" @@ -103,6 +104,9 @@ func newTrailListCmd() *cobra.Command { } func runTrailListAll(w io.Writer, statusFilter string, jsonOutput bool) error { + // Fetch remote trails branch so we see trails from collaborators + fetchTrailsBranch() + repo, err := strategy.OpenRepository() if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -622,6 +626,18 @@ func runTrailCreateInteractive(title, description, branch, statusStr *string) er return nil } +// fetchTrailsBranch fetches the remote trails branch so we see trails from collaborators. +// Best-effort: silently ignores errors (e.g., no remote, no network). +func fetchTrailsBranch() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + branchName := paths.TrailsBranchName + refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName) + //nolint:gosec // G204: branchName is a constant from paths package + cmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec) + _ = cmd.Run() //nolint:errcheck // best-effort fetch +} + // getTrailAuthor returns the GitHub username for the trail author. // Falls back to git user.name if gh CLI is unavailable or not authenticated. func getTrailAuthor(repo *git.Repository) string { From 9e7931901b97cddb9ce88f97543f79fa91ae747b Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Tue, 24 Feb 2026 10:35:09 +0100 Subject: [PATCH 14/24] Address PR review feedback - Fix FindByBranch comment to match actual (nil, nil) return contract - Use in-place shift for AddCheckpoint prepend to avoid full slice realloc - Set cmd.Stdin=nil and GIT_TERMINAL_PROMPT=0 on fetchTrailsBranch - Use logging package instead of fmt.Fprintf(os.Stderr) for auto-create trail - Revert accidental .entire/settings.json changes (wingman, telemetry) - Return (nil, nil) when agent doesn't support TextGenerator (non-fatal) - Remove raw LLM response from JSON parse error (privacy) - Decouple PushTrailsBranch from push_sessions setting - Fix interactive update silently resetting done/closed status to draft Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f4d9b8a4c6b4 --- .entire/settings.json | 8 +------- cmd/entire/cli/lifecycle.go | 3 ++- cmd/entire/cli/strategy/push_common.go | 24 +++++++++++++++--------- cmd/entire/cli/summarize/trail_title.go | 7 ++++--- cmd/entire/cli/trail/store.go | 9 ++++++--- cmd/entire/cli/trail_cmd.go | 17 +++++++++++++---- 6 files changed, 41 insertions(+), 27 deletions(-) diff --git a/.entire/settings.json b/.entire/settings.json index 59b841f19..885cc30a8 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -1,11 +1,5 @@ { "strategy": "manual-commit", "enabled": true, - "local_dev": true, - "strategy_options": { - "wingman": { - "enabled": true - } - }, - "telemetry": true + "local_dev": true } diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 7440d9d52..9dfe1add2 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -775,7 +775,8 @@ func autoCreateTrailOnTurnStart(prompt string) { } if err := AutoCreateTrail(repo, branchName, baseBranch, prompt); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to auto-create trail: %v\n", err) + ctx := context.Background() + logging.Warn(ctx, "failed to auto-create trail during turn start", slog.Any("error", err)) } } diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index af7d78784..07413ad00 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -30,6 +30,12 @@ func pushSessionsBranchCommon(remote, branchName string) error { return nil } + return pushBranchIfNeeded(remote, branchName) +} + +// pushBranchIfNeeded pushes a branch to the remote if it has unpushed changes. +// Does not check any settings — callers are responsible for gating. +func pushBranchIfNeeded(remote, branchName string) error { repo, err := OpenRepository() if err != nil { return nil //nolint:nilerr // Hook must be silent on failure @@ -49,7 +55,7 @@ func pushSessionsBranchCommon(remote, branchName string) error { return nil } - return doPushSessionsBranch(remote, branchName) + return doPushBranch(remote, branchName) } // hasUnpushedSessionsCommon checks if the local branch differs from the remote. @@ -78,9 +84,9 @@ func isPushSessionsDisabled() bool { return s.IsPushSessionsDisabled() } -// doPushSessionsBranch pushes the sessions branch to the remote. -func doPushSessionsBranch(remote, branchName string) error { - fmt.Fprintf(os.Stderr, "[entire] Pushing session logs to %s...\n", remote) +// doPushBranch pushes the given branch to the remote with fetch+merge recovery. +func doPushBranch(remote, branchName string) error { + fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...\n", branchName, remote) // Try pushing first if err := tryPushSessionsCommon(remote, branchName); err == nil { @@ -88,16 +94,16 @@ func doPushSessionsBranch(remote, branchName string) error { } // Push failed - likely non-fast-forward. Try to fetch and merge. - fmt.Fprintf(os.Stderr, "[entire] Syncing with remote session logs...\n") + fmt.Fprintf(os.Stderr, "[entire] Syncing %s with remote...\n", branchName) if err := fetchAndMergeSessionsCommon(remote, branchName); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync sessions: %v\n", err) + fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", branchName, err) return nil // Don't fail the main push } // Try pushing again after merge if err := tryPushSessionsCommon(remote, branchName); err != nil { - fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push sessions after sync: %v\n", err) + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", branchName, err) } return nil @@ -204,9 +210,9 @@ func fetchAndMergeSessionsCommon(remote, branchName string) error { } // PushTrailsBranch pushes the entire/trails branch to the remote. -// Reuses the same push/sync logic as session branches. +// Trails are always pushed regardless of the push_sessions setting. func PushTrailsBranch(remote string) error { - return pushSessionsBranchCommon(remote, paths.TrailsBranchName) + return pushBranchIfNeeded(remote, paths.TrailsBranchName) } // createMergeCommitCommon creates a merge commit with multiple parents. diff --git a/cmd/entire/cli/summarize/trail_title.go b/cmd/entire/cli/summarize/trail_title.go index e7b381670..6b5262beb 100644 --- a/cmd/entire/cli/summarize/trail_title.go +++ b/cmd/entire/cli/summarize/trail_title.go @@ -41,7 +41,7 @@ type TrailTitleResult struct { } // GenerateTrailTitle generates a title and description for a trail using the agent's -// text generation capability. Returns nil if the agent doesn't support text generation. +// text generation capability. Returns (nil, nil) if the agent doesn't support text generation. func GenerateTrailTitle(ctx context.Context, transcriptBytes []byte, filesTouched []string, agentType agent.AgentType) (*TrailTitleResult, error) { // Get the active agent and check if it implements TextGenerator ag, err := agent.GetByAgentType(agentType) @@ -50,7 +50,8 @@ func GenerateTrailTitle(ctx context.Context, transcriptBytes []byte, filesTouche } gen, ok := ag.(agent.TextGenerator) if !ok { - return nil, fmt.Errorf("agent %s does not support text generation", agentType) + // Agent does not support text generation: treat as non-fatal and return no result. + return nil, nil //nolint:nilnil // nil result signals "not supported", not an error } // Build condensed transcript (reuse existing infrastructure) @@ -76,7 +77,7 @@ func GenerateTrailTitle(ctx context.Context, transcriptBytes []byte, filesTouche cleaned := extractJSONFromMarkdown(rawResult) var result TrailTitleResult if err := json.Unmarshal([]byte(cleaned), &result); err != nil { - return nil, fmt.Errorf("failed to parse trail title JSON: %w (response: %s)", err, cleaned) + return nil, fmt.Errorf("failed to parse trail title JSON: %w", err) } return &result, nil diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go index 8416c22b8..f5e93bc16 100644 --- a/cmd/entire/cli/trail/store.go +++ b/cmd/entire/cli/trail/store.go @@ -218,7 +218,7 @@ func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { } // FindByBranch finds a trail for the given branch name. -// Returns nil, nil, nil if no trail exists for the branch. +// Returns (nil, nil) if no trail exists for the branch. func (s *Store) FindByBranch(branchName string) (*Metadata, error) { trails, err := s.List() if err != nil { @@ -300,8 +300,11 @@ func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { checkpoints = &Checkpoints{Checkpoints: []CheckpointRef{}} } - // Prepend new ref (newest first) - checkpoints.Checkpoints = append([]CheckpointRef{ref}, checkpoints.Checkpoints...) + // Prepend new ref (newest first) without always allocating a new slice. + // Grow the slice by one, shift existing elements right, and insert at index 0. + checkpoints.Checkpoints = append(checkpoints.Checkpoints, CheckpointRef{}) + copy(checkpoints.Checkpoints[1:], checkpoints.Checkpoints[:len(checkpoints.Checkpoints)-1]) + checkpoints.Checkpoints[0] = ref return s.Write(metadata, discussion, checkpoints) } diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 2229ae6e5..57bef1b33 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "io" + "log/slog" "os" "os/exec" "sort" "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trail" @@ -382,11 +384,12 @@ func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, l // Interactive mode when no flags are provided noFlags := statusStr == "" && title == "" && description == "" && labelAdd == nil && labelRemove == nil if noFlags { - // Build status options with current value as default - // Exclude "done" (set on merge) and "closed" (requires permissions) + // Build status options with current value as default. + // Exclude "done" and "closed" unless the trail is already in that status + // (otherwise the select would silently reset to the first option). var statusOptions []huh.Option[string] for _, s := range trail.ValidStatuses() { - if s == trail.StatusDone || s == trail.StatusClosed { + if (s == trail.StatusDone || s == trail.StatusClosed) && s != metadata.Status { continue } label := string(s) @@ -497,7 +500,10 @@ func AutoCreateTrail(repo *git.Repository, branchName, baseBranch, prompt string return fmt.Errorf("failed to create trail: %w", err) } - fmt.Fprintf(os.Stderr, "[entire] Created trail for branch: %s\n", branchName) + logCtx := context.Background() + logging.Info(logCtx, "auto-created trail for branch", + slog.String("branch", branchName), + slog.String("trail_id", trailID.String())) return nil } @@ -635,6 +641,9 @@ func fetchTrailsBranch() { refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName) //nolint:gosec // G204: branchName is a constant from paths package cmd := exec.CommandContext(ctx, "git", "fetch", "origin", refSpec) + // Ensure non-interactive fetch in hook/agent contexts + cmd.Stdin = nil + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") _ = cmd.Run() //nolint:errcheck // best-effort fetch } From 9d64e48767d5a653df1537ac8856a7a7c10098f5 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Tue, 24 Feb 2026 14:15:27 +0100 Subject: [PATCH 15/24] Rename trail "description" to "body" Aligns with updated spec: the trail's long-form content field is now "body" (JSON key, struct field, CLI flag, and interactive form label). Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: c33d8f528b3d --- .../cli/strategy/manual_commit_hooks.go | 8 +-- cmd/entire/cli/summarize/trail_title.go | 10 +-- cmd/entire/cli/trail/store_test.go | 22 +++--- cmd/entire/cli/trail/trail.go | 24 +++---- cmd/entire/cli/trail_cmd.go | 68 +++++++++---------- 5 files changed, 66 insertions(+), 66 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index cfcfb033d..1f700b0da 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1959,8 +1959,8 @@ func generateTrailTitleFromTranscript(repo *git.Repository, branchName string, t if err != nil || existing == nil { return } - // Only generate once: skip if description already set - if existing.Description != "" { + // Only generate once: skip if body already set + if existing.Body != "" { return } @@ -1977,8 +1977,8 @@ func generateTrailTitleFromTranscript(repo *git.Repository, branchName string, t if result.Title != "" { m.Title = result.Title } - if result.Description != "" { - m.Description = result.Description + if result.Body != "" { + m.Body = result.Body } }) } diff --git a/cmd/entire/cli/summarize/trail_title.go b/cmd/entire/cli/summarize/trail_title.go index 6b5262beb..65bd59582 100644 --- a/cmd/entire/cli/summarize/trail_title.go +++ b/cmd/entire/cli/summarize/trail_title.go @@ -22,22 +22,22 @@ const trailTitlePromptTemplate = `Analyze this development session transcript an Return a JSON object: { "title": "Short imperative title (max 80 chars)", - "description": "1-3 sentence description of what was accomplished and why" + "body": "1-3 sentence description of what was accomplished and why" } Guidelines: - Title: imperative mood, captures core intent (e.g. "Add user authentication flow") -- Description: explain the "what" and "why", not the "how" +- Body: explain the "what" and "why", not the "how" - Return ONLY the JSON object` // trailTitleModel is the model hint for trail title generation. // Haiku is fast (~1-2s) and cheap — trail titles are simple tasks. const trailTitleModel = "haiku" -// TrailTitleResult contains the LLM-generated title and description for a trail. +// TrailTitleResult contains the LLM-generated title and body for a trail. type TrailTitleResult struct { - Title string `json:"title"` - Description string `json:"description"` + Title string `json:"title"` + Body string `json:"body"` } // GenerateTrailTitle generates a title and description for a trail using the agent's diff --git a/cmd/entire/cli/trail/store_test.go b/cmd/entire/cli/trail/store_test.go index abbf46744..79799ca38 100644 --- a/cmd/entire/cli/trail/store_test.go +++ b/cmd/entire/cli/trail/store_test.go @@ -80,17 +80,17 @@ func TestStore_WriteAndRead(t *testing.T) { now := time.Now().Truncate(time.Second) metadata := &Metadata{ - TrailID: trailID, - Branch: "feature/test", - Base: "main", - Title: "Test trail", - Description: "A test trail", - Status: StatusDraft, - Author: "tester", - Assignees: []string{}, - Labels: []string{"test"}, - CreatedAt: now, - UpdatedAt: now, + TrailID: trailID, + Branch: "feature/test", + Base: "main", + Title: "Test trail", + Body: "A test trail", + Status: StatusDraft, + Author: "tester", + Assignees: []string{}, + Labels: []string{"test"}, + CreatedAt: now, + UpdatedAt: now, } discussion := &Discussion{Comments: []Comment{}} diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go index 37bc9e010..4a560f595 100644 --- a/cmd/entire/cli/trail/trail.go +++ b/cmd/entire/cli/trail/trail.go @@ -97,18 +97,18 @@ func (s Status) IsValid() bool { // Metadata represents the metadata for a trail, matching the web PR format. type Metadata struct { - TrailID ID `json:"trail_id"` - Branch string `json:"branch"` - Base string `json:"base"` - Title string `json:"title"` - Description string `json:"description"` - Status Status `json:"status"` - Author string `json:"author"` - Assignees []string `json:"assignees"` - Labels []string `json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - MergedAt *time.Time `json:"merged_at,omitempty"` + TrailID ID `json:"trail_id"` + Branch string `json:"branch"` + Base string `json:"base"` + Title string `json:"title"` + Body string `json:"body"` + Status Status `json:"status"` + Author string `json:"author"` + Assignees []string `json:"assignees"` + Labels []string `json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` } // Discussion holds the discussion/comments for a trail. diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 57bef1b33..fd2eec15b 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -74,8 +74,8 @@ func printTrailDetails(w io.Writer, m *trail.Metadata) { fmt.Fprintf(w, " Base: %s\n", m.Base) fmt.Fprintf(w, " Status: %s\n", m.Status) fmt.Fprintf(w, " Author: %s\n", m.Author) - if m.Description != "" { - fmt.Fprintf(w, " Description: %s\n", m.Description) + if m.Body != "" { + fmt.Fprintf(w, " Body: %s\n", m.Body) } if len(m.Labels) > 0 { fmt.Fprintf(w, " Labels: %s\n", strings.Join(m.Labels, ", ")) @@ -176,19 +176,19 @@ func runTrailListAll(w io.Writer, statusFilter string, jsonOutput bool) error { } func newTrailCreateCmd() *cobra.Command { - var title, description, base, branch, status string + var title, body, base, branch, status string var checkout bool cmd := &cobra.Command{ Use: "create", Short: "Create a trail for the current or a new branch", RunE: func(cmd *cobra.Command, _ []string) error { - return runTrailCreate(cmd.OutOrStdout(), cmd.ErrOrStderr(), title, description, base, branch, status, checkout) + return runTrailCreate(cmd.OutOrStdout(), cmd.ErrOrStderr(), title, body, base, branch, status, checkout) }, } cmd.Flags().StringVar(&title, "title", "", "Trail title") - cmd.Flags().StringVar(&description, "description", "", "Trail description") + cmd.Flags().StringVar(&body, "body", "", "Trail body") cmd.Flags().StringVar(&base, "base", "", "Base branch (defaults to detected default branch)") cmd.Flags().StringVar(&branch, "branch", "", "Branch for the trail (defaults to current branch)") cmd.Flags().StringVar(&status, "status", "", "Initial status (defaults to draft)") @@ -198,7 +198,7 @@ func newTrailCreateCmd() *cobra.Command { } //nolint:cyclop // sequential steps for creating a trail — splitting would obscure the flow -func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusStr string, checkout bool) error { +func runTrailCreate(w, errW io.Writer, title, body, base, branch, statusStr string, checkout bool) error { repo, err := strategy.OpenRepository() if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -216,8 +216,8 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS interactive := !hasFlag("title") && !hasFlag("branch") if interactive { - // Interactive flow: title → description → branch (derived) → status - if err := runTrailCreateInteractive(&title, &description, &branch, &statusStr); err != nil { + // Interactive flow: title → body → branch (derived) → status + if err := runTrailCreateInteractive(&title, &body, &branch, &statusStr); err != nil { return err } } else { @@ -278,17 +278,17 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS now := time.Now() metadata := &trail.Metadata{ - TrailID: trailID, - Branch: branch, - Base: base, - Title: title, - Description: description, - Status: trailStatus, - Author: authorName, - Assignees: []string{}, - Labels: []string{}, - CreatedAt: now, - UpdatedAt: now, + TrailID: trailID, + Branch: branch, + Base: base, + Title: title, + Body: body, + Status: trailStatus, + Author: authorName, + Assignees: []string{}, + Labels: []string{}, + CreatedAt: now, + UpdatedAt: now, } if err := store.Write(metadata, nil, nil); err != nil { @@ -337,20 +337,20 @@ func runTrailCreate(w, errW io.Writer, title, description, base, branch, statusS } func newTrailUpdateCmd() *cobra.Command { - var statusStr, title, description, branch string + var statusStr, title, body, branch string var labelAdd, labelRemove []string cmd := &cobra.Command{ Use: "update", Short: "Update trail metadata", RunE: func(cmd *cobra.Command, _ []string) error { - return runTrailUpdate(cmd.OutOrStdout(), statusStr, title, description, branch, labelAdd, labelRemove) + return runTrailUpdate(cmd.OutOrStdout(), statusStr, title, body, branch, labelAdd, labelRemove) }, } cmd.Flags().StringVar(&statusStr, "status", "", "Update status") cmd.Flags().StringVar(&title, "title", "", "Update title") - cmd.Flags().StringVar(&description, "description", "", "Update description") + cmd.Flags().StringVar(&body, "body", "", "Update body") cmd.Flags().StringVar(&branch, "branch", "", "Branch to update trail for (defaults to current)") cmd.Flags().StringSliceVar(&labelAdd, "add-label", nil, "Add label(s)") cmd.Flags().StringSliceVar(&labelRemove, "remove-label", nil, "Remove label(s)") @@ -358,7 +358,7 @@ func newTrailUpdateCmd() *cobra.Command { return cmd } -func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, labelAdd, labelRemove []string) error { +func runTrailUpdate(w io.Writer, statusStr, title, body, branch string, labelAdd, labelRemove []string) error { repo, err := strategy.OpenRepository() if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -382,7 +382,7 @@ func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, l } // Interactive mode when no flags are provided - noFlags := statusStr == "" && title == "" && description == "" && labelAdd == nil && labelRemove == nil + noFlags := statusStr == "" && title == "" && body == "" && labelAdd == nil && labelRemove == nil if noFlags { // Build status options with current value as default. // Exclude "done" and "closed" unless the trail is already in that status @@ -400,7 +400,7 @@ func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, l } statusStr = string(metadata.Status) title = metadata.Title - description = metadata.Description + body = metadata.Body form := NewAccessibleForm( huh.NewGroup( @@ -412,8 +412,8 @@ func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, l Title("Title"). Value(&title), huh.NewText(). - Title("Description"). - Value(&description), + Title("Body"). + Value(&body), ), ) if formErr := form.Run(); formErr != nil { @@ -436,8 +436,8 @@ func runTrailUpdate(w io.Writer, statusStr, title, description, branch string, l if title != "" { m.Title = title } - if description != "" { - m.Description = description + if body != "" { + m.Body = body } for _, l := range labelAdd { if !containsString(m.Labels, l) { @@ -572,9 +572,9 @@ func removeString(slice []string, s string) []string { } // runTrailCreateInteractive runs the interactive form for trail creation. -// Prompts for title, description, branch (derived from title), and status. -func runTrailCreateInteractive(title, description, branch, statusStr *string) error { - // Step 1: Title and description +// Prompts for title, body, branch (derived from title), and status. +func runTrailCreateInteractive(title, body, branch, statusStr *string) error { + // Step 1: Title and body form := NewAccessibleForm( huh.NewGroup( huh.NewInput(). @@ -582,8 +582,8 @@ func runTrailCreateInteractive(title, description, branch, statusStr *string) er Placeholder("What are you working on?"). Value(title), huh.NewText(). - Title("Description (optional)"). - Value(description), + Title("Body (optional)"). + Value(body), ), ) if err := form.Run(); err != nil { From 2df848d97e13c56f8b7d1fa5fe538872e1e303b2 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Tue, 24 Feb 2026 14:16:50 +0100 Subject: [PATCH 16/24] =?UTF-8?q?Update=20CLAUDE.md=20trail=20docs:=20desc?= =?UTF-8?q?ription=20=E2=86=92=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: dfe58c8924e7 --- CLAUDE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4e9271d95..4d19ccf24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,8 +352,8 @@ All strategies implement: **Command:** `entire trail` — manage trails for branches - `entire trail` — show current branch's trail, or list all - `entire trail list` — list all trails (`--status`, `--json` flags) -- `entire trail create` — create a trail (interactive or via `--title`, `--branch`, `--status` flags) -- `entire trail update` — update trail metadata (`--status`, `--title`, `--add-label`, `--remove-label` flags) +- `entire trail create` — create a trail (interactive or via `--title`, `--body`, `--branch`, `--status` flags) +- `entire trail update` — update trail metadata (`--status`, `--title`, `--body`, `--add-label`, `--remove-label` flags) **Auto-create:** On session TurnStart, if on a non-main branch without a trail, one is auto-created with `in_progress` status. The trail title is derived from the user's first prompt (first line, max 80 chars); falls back to `HumanizeBranchName()` if no prompt is available. @@ -364,7 +364,7 @@ All strategies implement: **Storage:** `entire/trails` orphan branch with sharded paths: ``` // -├── metadata.json # Trail metadata (title, branch, status, etc.) +├── metadata.json # Trail metadata (title, body, branch, status, etc.) ├── discussion.json # Comments and replies └── checkpoints.json # Checkpoint references (newest first) ``` From 488c4b01ae3a40e1dbe9408920c90a60e7938a2e Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Tue, 24 Feb 2026 17:46:37 +0100 Subject: [PATCH 17/24] Revert CLAUDE.md to main Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: b91a1b7a3dc6 --- CLAUDE.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4d19ccf24..7ffdba5c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,6 @@ This repo contains the CLI for Entire. - `entire/`: Main CLI entry point - `entire/cli`: CLI utilities and helpers - `entire/cli/commands`: actual command implementations -- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) - `entire/cli/strategy`: strategy implementations - see section below - `entire/cli/checkpoint`: checkpoint storage abstractions (temporary and committed) - `entire/cli/session`: session state management @@ -345,46 +344,6 @@ All strategies implement: - `temporary.go` - Shadow branch operations (`WriteTemporary`, `ReadTemporary`, `ListTemporary`) - `committed.go` - Metadata branch operations (`WriteCommitted`, `ReadCommitted`, `ListCommitted`) -#### Trail Package (`cmd/entire/cli/trail/`) -- `trail.go` - Types (`ID`, `Status`, `Metadata`, `Discussion`, `Comment`, `CheckpointRef`, `Checkpoints`) and `HumanizeBranchName()` helper -- `store.go` - `Store` struct with CRUD operations on the `entire/trails` orphan branch - -**Command:** `entire trail` — manage trails for branches -- `entire trail` — show current branch's trail, or list all -- `entire trail list` — list all trails (`--status`, `--json` flags) -- `entire trail create` — create a trail (interactive or via `--title`, `--body`, `--branch`, `--status` flags) -- `entire trail update` — update trail metadata (`--status`, `--title`, `--body`, `--add-label`, `--remove-label` flags) - -**Auto-create:** On session TurnStart, if on a non-main branch without a trail, one is auto-created with `in_progress` status. The trail title is derived from the user's first prompt (first line, max 80 chars); falls back to `HumanizeBranchName()` if no prompt is available. - -**Checkpoint linking:** After each condensation (manual-commit PostCommit) or save (auto-commit SaveStep), the checkpoint is appended to the trail's `checkpoints.json` (newest first). This is best-effort and non-blocking. - -**Push:** Pre-push hooks push `entire/trails` branch alongside `entire/checkpoints/v1`. - -**Storage:** `entire/trails` orphan branch with sharded paths: -``` -// -├── metadata.json # Trail metadata (title, body, branch, status, etc.) -├── discussion.json # Comments and replies -└── checkpoints.json # Checkpoint references (newest first) -``` - -**checkpoints.json format:** -```json -{ - "checkpoints": [ - { - "checkpoint_id": "a3b2c4d5e6f7", - "commit_sha": "abc123...", - "created_at": "2026-01-13T12:00:00Z", - "summary": "First line of the user prompt" - } - ] -} -``` - -**Status lifecycle:** `draft → open → in_progress → in_review → done → closed` - #### Session Package (`cmd/entire/cli/session/`) - `session.go` - Session data types and interfaces - `state.go` - `StateStore` for managing `.git/entire-sessions/` files From 9b93ae6e33f4bd3f2db72afddf66efc2fef5bdb4 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 27 Feb 2026 10:40:15 +0100 Subject: [PATCH 18/24] Remove automatic trail creation on session start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trails are now purely manual — created, updated, and deleted only via `entire trail` subcommands. Removes autoCreateTrailOnTurnStart(), AutoCreateTrail(), and titleFromPrompt(). Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: c71f63ac5806 --- cmd/entire/cli/lifecycle.go | 28 ---------------- cmd/entire/cli/trail_cmd.go | 65 ------------------------------------- 2 files changed, 93 deletions(-) diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 3071b47e7..d48d0fbf7 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -138,9 +138,6 @@ func handleLifecycleTurnStart(ctx context.Context, ag agent.Agent, event *agent. fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", err) } - // Auto-create trail for non-main branches - autoCreateTrailOnTurnStart(event.Prompt) - return nil } @@ -703,31 +700,6 @@ func markSessionEnded(ctx context.Context, sessionID string) error { return nil } -// autoCreateTrailOnTurnStart creates a trail if on a non-main branch without one. -// Non-blocking: logs errors but doesn't fail the session. -// prompt is the user's prompt text, used for the trail title. -func autoCreateTrailOnTurnStart(prompt string) { - isDefault, branchName, err := IsOnDefaultBranch(context.Background()) - if err != nil || isDefault || branchName == "" { - return - } - - repo, err := strategy.OpenRepository(context.Background()) - if err != nil { - return - } - - baseBranch := strategy.GetDefaultBranchName(repo) - if baseBranch == "" { - baseBranch = defaultBaseBranch - } - - if err := AutoCreateTrail(repo, branchName, baseBranch, prompt); err != nil { - ctx := context.Background() - logging.Warn(ctx, "failed to auto-create trail during turn start", slog.Any("error", err)) - } -} - // logFileChanges logs the files modified, created, and deleted during a session. func logFileChanges(modified, newFiles, deleted []string) { fmt.Fprintf(os.Stderr, "Files modified during session (%d):\n", len(modified)) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 1da0e27b4..fe0d5832e 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -6,14 +6,12 @@ import ( "errors" "fmt" "io" - "log/slog" "os" "os/exec" "sort" "strings" "time" - "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trail" @@ -459,69 +457,6 @@ func runTrailUpdate(w io.Writer, statusStr, title, body, branch string, labelAdd // defaultBaseBranch is the fallback base branch name when it cannot be determined. const defaultBaseBranch = "main" -// AutoCreateTrail creates a trail automatically for the current branch if one doesn't exist. -// This is called during session start for non-main branches. -// If prompt is non-empty, its first line is used as the trail title instead of the branch name. -func AutoCreateTrail(repo *git.Repository, branchName, baseBranch, prompt string) error { - store := trail.NewStore(repo) - - existing, err := store.FindByBranch(branchName) - if err == nil && existing != nil { - return nil // Trail already exists - } - - trailID, err := trail.GenerateID() - if err != nil { - return fmt.Errorf("failed to generate trail ID: %w", err) - } - - authorName := getTrailAuthor(repo) - now := time.Now() - - title := titleFromPrompt(prompt) - if title == "" { - title = trail.HumanizeBranchName(branchName) - } - - metadata := &trail.Metadata{ - TrailID: trailID, - Branch: branchName, - Base: baseBranch, - Title: title, - Status: trail.StatusInProgress, - Author: authorName, - Assignees: []string{}, - Labels: []string{}, - CreatedAt: now, - UpdatedAt: now, - } - - if err := store.Write(metadata, nil, nil); err != nil { - return fmt.Errorf("failed to create trail: %w", err) - } - - logCtx := context.Background() - logging.Info(logCtx, "auto-created trail for branch", - slog.String("branch", branchName), - slog.String("trail_id", trailID.String())) - return nil -} - -// titleFromPrompt extracts a trail title from the user's prompt. -// Uses the first line, trimmed and truncated to 80 characters. -// Returns empty string if prompt is empty. -func titleFromPrompt(prompt string) string { - if prompt == "" { - return "" - } - line, _, _ := strings.Cut(prompt, "\n") - title := strings.TrimSpace(line) - if len(title) > 80 { - title = title[:77] + "..." - } - return title -} - // hasFlag is a simple helper that checks os.Args for --flag presence. // Used to distinguish between "flag not provided" and "flag provided with empty value". func hasFlag(name string) bool { From 189eb4554a2421f9016819218c250138d646fb31 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Fri, 27 Feb 2026 15:28:04 +0100 Subject: [PATCH 19/24] Hide trail command from main help output The trail command is still fully functional via `entire trail` but no longer appears in `entire help` output. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: a52b774246db --- cmd/entire/cli/trail_cmd.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index fe0d5832e..1366886bb 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -24,8 +24,9 @@ import ( func newTrailCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "trail", - Short: "Manage trails for your branches", + Use: "trail", + Short: "Manage trails for your branches", + Hidden: true, Long: `Trails are branch-centric work tracking abstractions. They describe the "why" and "what" of your work, while checkpoints capture the "how" and "when". From cef3938d6f9a678a364567482033a219dc0d22db Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Mon, 2 Mar 2026 20:22:27 +0100 Subject: [PATCH 20/24] simplify Entire-Checkpoint: 4cb9291bb58a --- .../cli/strategy/manual_commit_hooks.go | 69 ++++++------------- cmd/entire/cli/trail_cmd.go | 65 ++++------------- 2 files changed, 37 insertions(+), 97 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 93d3406ea..26237eee7 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -1008,13 +1008,17 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( return false } - // Link checkpoint to trail (best-effort) - appendCheckpointToTrail(repo, result.CheckpointID, head.Hash(), result.Prompts) - - // Generate trail title/description from transcript (best-effort, first condensation only) + // Link checkpoint to trail and optionally generate title (best-effort) branchName := GetCurrentBranchName(repo) - if branchName != "" && branchName != GetDefaultBranchName(repo) && len(result.Transcript) > 0 { - generateTrailTitleFromTranscript(repo, branchName, result.Transcript, result.FilesTouched, state.AgentType) + if branchName != "" && branchName != GetDefaultBranchName(repo) { + store := trail.NewStore(repo) + existing, findErr := store.FindByBranch(branchName) + if findErr == nil && existing != nil { + appendCheckpointToExistingTrail(store, existing.TrailID, result.CheckpointID, head.Hash(), result.Prompts) + if existing.Body == "" && len(result.Transcript) > 0 { + generateTrailTitleForTrail(store, existing.TrailID, result.Transcript, result.FilesTouched, state.AgentType) + } + } } // Track this shadow branch for cleanup @@ -2233,24 +2237,17 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( ) } -// generateTrailTitleFromTranscript uses the agent's text generation capability +// generateTrailTitleForTrail uses the agent's text generation capability // to generate a proper title and description for the trail. Best-effort: silently -// returns on any error. Only generates once (skips if description already set). -func generateTrailTitleFromTranscript(repo *git.Repository, branchName string, transcriptBytes []byte, filesTouched []string, agentType types.AgentType) { +// returns on any error. +func generateTrailTitleForTrail(store *trail.Store, trailID trail.ID, transcriptBytes []byte, filesTouched []string, agentType types.AgentType) { if !settings.IsSummarizeEnabled(context.Background()) { return } - store := trail.NewStore(repo) - existing, err := store.FindByBranch(branchName) - if err != nil || existing == nil { - return - } - // Only generate once: skip if body already set - if existing.Body != "" { - return - } - logCtx := logging.WithComponent(context.Background(), "trail-title") + logCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + logCtx = logging.WithComponent(logCtx, "trail-title") result, err := summarize.GenerateTrailTitle(logCtx, transcriptBytes, filesTouched, agentType) if err != nil { logging.Debug(logCtx, "trail title generation skipped", @@ -2259,7 +2256,7 @@ func generateTrailTitleFromTranscript(repo *git.Repository, branchName string, t } //nolint:errcheck,gosec // best-effort: trail title generation is non-critical - store.Update(existing.TrailID, func(m *trail.Metadata) { + store.Update(trailID, func(m *trail.Metadata) { if result.Title != "" { m.Title = result.Title } @@ -2269,31 +2266,16 @@ func generateTrailTitleFromTranscript(repo *git.Repository, branchName string, t }) } -// appendCheckpointToTrail links a checkpoint to the trail for the current branch. +// appendCheckpointToExistingTrail links a checkpoint to the given trail. // Best-effort: silently returns on any error (trails are non-critical metadata). -func appendCheckpointToTrail(repo *git.Repository, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { - branchName := GetCurrentBranchName(repo) - if branchName == "" { - return - } - defaultBranch := GetDefaultBranchName(repo) - if branchName == defaultBranch { - return - } - - store := trail.NewStore(repo) - existing, err := store.FindByBranch(branchName) - if err != nil || existing == nil { - return - } - +func appendCheckpointToExistingTrail(store *trail.Store, trailID trail.ID, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { var summary string if len(prompts) > 0 { summary = truncateForSummary(prompts[len(prompts)-1], 200) } //nolint:errcheck,gosec // best-effort: trail checkpoint linking is non-critical - store.AddCheckpoint(existing.TrailID, trail.CheckpointRef{ + store.AddCheckpoint(trailID, trail.CheckpointRef{ CheckpointID: cpID.String(), CommitSHA: commitSHA.String(), CreatedAt: time.Now().UTC(), @@ -2301,16 +2283,9 @@ func appendCheckpointToTrail(repo *git.Repository, cpID id.CheckpointID, commitS }) } -// truncateForSummary truncates a string to maxLen, adding "..." if truncated. -// Takes the first line only to keep summaries concise. +// truncateForSummary takes the first line of s and truncates to maxLen runes. func truncateForSummary(s string, maxLen int) string { line, _, _ := strings.Cut(s, "\n") line = strings.TrimSpace(line) - if len(line) <= maxLen { - return line - } - if maxLen <= 3 { - return line[:maxLen] - } - return line[:maxLen-3] + "..." + return stringutil.TruncateRunes(line, maxLen, "...") } diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 1366886bb..cd0b341b4 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -8,12 +8,14 @@ import ( "io" "os" "os/exec" + "slices" "sort" "strings" "time" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/stringutil" "github.com/entireio/cli/cmd/entire/cli/trail" "github.com/charmbracelet/huh" @@ -165,10 +167,10 @@ func runTrailListAll(w io.Writer, statusFilter string, jsonOutput bool) error { // Table output fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", "BRANCH", "TITLE", "STATUS", "AUTHOR", "UPDATED") for _, t := range trails { - branch := truncate(t.Branch, 30) - title := truncate(t.Title, 40) + branch := stringutil.TruncateRunes(t.Branch, 30, "...") + title := stringutil.TruncateRunes(t.Title, 40, "...") fmt.Fprintf(w, "%-30s %-40s %-13s %-15s %s\n", - branch, title, t.Status, truncate(t.Author, 15), timeAgo(t.UpdatedAt)) + branch, title, t.Status, stringutil.TruncateRunes(t.Author, 15, "..."), timeAgo(t.UpdatedAt)) } return nil @@ -182,7 +184,7 @@ func newTrailCreateCmd() *cobra.Command { Use: "create", Short: "Create a trail for the current or a new branch", RunE: func(cmd *cobra.Command, _ []string) error { - return runTrailCreate(cmd.OutOrStdout(), cmd.ErrOrStderr(), title, body, base, branch, status, checkout) + return runTrailCreate(cmd, title, body, base, branch, status, checkout) }, } @@ -197,7 +199,10 @@ func newTrailCreateCmd() *cobra.Command { } //nolint:cyclop // sequential steps for creating a trail — splitting would obscure the flow -func runTrailCreate(w, errW io.Writer, title, body, base, branch, statusStr string, checkout bool) error { +func runTrailCreate(cmd *cobra.Command, title, body, base, branch, statusStr string, checkout bool) error { + w := cmd.OutOrStdout() + errW := cmd.ErrOrStderr() + repo, err := strategy.OpenRepository(context.Background()) if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -212,7 +217,7 @@ func runTrailCreate(w, errW io.Writer, title, body, base, branch, statusStr stri } _, currentBranch, _ := IsOnDefaultBranch(context.Background()) //nolint:errcheck // best-effort detection - interactive := !hasFlag("title") && !hasFlag("branch") + interactive := !cmd.Flags().Changed("title") && !cmd.Flags().Changed("branch") if interactive { // Interactive flow: title → body → branch (derived) → status @@ -222,7 +227,7 @@ func runTrailCreate(w, errW io.Writer, title, body, base, branch, statusStr stri } else { // Non-interactive: derive missing values from provided flags if branch == "" { - if hasFlag("title") { + if cmd.Flags().Changed("title") { branch = slugifyTitle(title) } else { branch = currentBranch @@ -311,7 +316,7 @@ func runTrailCreate(w, errW io.Writer, title, body, base, branch, statusStr stri // Checkout the branch if requested or prompted if needsCreation && currentBranch != branch { shouldCheckout := checkout - if !shouldCheckout && !hasFlag("checkout") { + if !shouldCheckout && !cmd.Flags().Changed("checkout") { // Interactive: ask whether to checkout form := NewAccessibleForm( huh.NewGroup( @@ -439,12 +444,12 @@ func runTrailUpdate(w io.Writer, statusStr, title, body, branch string, labelAdd m.Body = body } for _, l := range labelAdd { - if !containsString(m.Labels, l) { + if !slices.Contains(m.Labels, l) { m.Labels = append(m.Labels, l) } } for _, l := range labelRemove { - m.Labels = removeString(m.Labels, l) + m.Labels = slices.DeleteFunc(m.Labels, func(v string) bool { return v == l }) } }) if err != nil { @@ -458,27 +463,6 @@ func runTrailUpdate(w io.Writer, statusStr, title, body, branch string, labelAdd // defaultBaseBranch is the fallback base branch name when it cannot be determined. const defaultBaseBranch = "main" -// hasFlag is a simple helper that checks os.Args for --flag presence. -// Used to distinguish between "flag not provided" and "flag provided with empty value". -func hasFlag(name string) bool { - for _, arg := range os.Args { - if arg == "--"+name || strings.HasPrefix(arg, "--"+name+"=") { - return true - } - } - return false -} - -func truncate(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - if maxLen <= 3 { - return s[:maxLen] - } - return s[:maxLen-3] + "..." -} - func formatValidStatuses() string { statuses := trail.ValidStatuses() names := make([]string, len(statuses)) @@ -488,25 +472,6 @@ func formatValidStatuses() string { return strings.Join(names, ", ") } -func containsString(slice []string, s string) bool { - for _, v := range slice { - if v == s { - return true - } - } - return false -} - -func removeString(slice []string, s string) []string { - var result []string - for _, v := range slice { - if v != s { - result = append(result, v) - } - } - return result -} - // runTrailCreateInteractive runs the interactive form for trail creation. // Prompts for title, body, branch (derived from title), and status. func runTrailCreateInteractive(title, body, branch, statusStr *string) error { From 8b01d8e0315c2d38edd4916e69d268ecc2f9064d Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Mon, 2 Mar 2026 21:53:49 +0100 Subject: [PATCH 21/24] Remove trail title generation Trails no longer auto-generate titles/descriptions via LLM. Users set these manually when creating or updating trails. Co-Authored-By: Claude Opus 4.5 Entire-Checkpoint: 6c28847c18ef --- .../cli/strategy/manual_commit_hooks.go | 35 +------- cmd/entire/cli/summarize/trail_title.go | 85 ------------------- 2 files changed, 1 insertion(+), 119 deletions(-) delete mode 100644 cmd/entire/cli/summarize/trail_title.go diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 26237eee7..8f047f6d4 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -24,7 +24,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/stringutil" - "github.com/entireio/cli/cmd/entire/cli/summarize" "github.com/entireio/cli/cmd/entire/cli/trail" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/redact" @@ -1008,16 +1007,13 @@ func (s *ManualCommitStrategy) condenseAndUpdateState( return false } - // Link checkpoint to trail and optionally generate title (best-effort) + // Link checkpoint to trail (best-effort) branchName := GetCurrentBranchName(repo) if branchName != "" && branchName != GetDefaultBranchName(repo) { store := trail.NewStore(repo) existing, findErr := store.FindByBranch(branchName) if findErr == nil && existing != nil { appendCheckpointToExistingTrail(store, existing.TrailID, result.CheckpointID, head.Hash(), result.Prompts) - if existing.Body == "" && len(result.Transcript) > 0 { - generateTrailTitleForTrail(store, existing.TrailID, result.Transcript, result.FilesTouched, state.AgentType) - } } } @@ -2237,35 +2233,6 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( ) } -// generateTrailTitleForTrail uses the agent's text generation capability -// to generate a proper title and description for the trail. Best-effort: silently -// returns on any error. -func generateTrailTitleForTrail(store *trail.Store, trailID trail.ID, transcriptBytes []byte, filesTouched []string, agentType types.AgentType) { - if !settings.IsSummarizeEnabled(context.Background()) { - return - } - - logCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - logCtx = logging.WithComponent(logCtx, "trail-title") - result, err := summarize.GenerateTrailTitle(logCtx, transcriptBytes, filesTouched, agentType) - if err != nil { - logging.Debug(logCtx, "trail title generation skipped", - slog.String("error", err.Error())) - return - } - - //nolint:errcheck,gosec // best-effort: trail title generation is non-critical - store.Update(trailID, func(m *trail.Metadata) { - if result.Title != "" { - m.Title = result.Title - } - if result.Body != "" { - m.Body = result.Body - } - }) -} - // appendCheckpointToExistingTrail links a checkpoint to the given trail. // Best-effort: silently returns on any error (trails are non-critical metadata). func appendCheckpointToExistingTrail(store *trail.Store, trailID trail.ID, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { diff --git a/cmd/entire/cli/summarize/trail_title.go b/cmd/entire/cli/summarize/trail_title.go deleted file mode 100644 index a75c60e21..000000000 --- a/cmd/entire/cli/summarize/trail_title.go +++ /dev/null @@ -1,85 +0,0 @@ -package summarize - -import ( - "context" - "encoding/json" - "errors" - "fmt" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/types" -) - -// trailTitlePromptTemplate is the prompt used to generate trail titles and descriptions. -// -// Security note: The transcript is wrapped in tags to provide clear boundary -// markers. This helps contain any potentially malicious content within the transcript. -const trailTitlePromptTemplate = `Analyze this development session transcript and generate a title and description. - - -%s - - -Return a JSON object: -{ - "title": "Short imperative title (max 80 chars)", - "body": "1-3 sentence description of what was accomplished and why" -} - -Guidelines: -- Title: imperative mood, captures core intent (e.g. "Add user authentication flow") -- Body: explain the "what" and "why", not the "how" -- Return ONLY the JSON object` - -// trailTitleModel is the model hint for trail title generation. -// Haiku is fast (~1-2s) and cheap — trail titles are simple tasks. -const trailTitleModel = "haiku" - -// TrailTitleResult contains the LLM-generated title and body for a trail. -type TrailTitleResult struct { - Title string `json:"title"` - Body string `json:"body"` -} - -// GenerateTrailTitle generates a title and description for a trail using the agent's -// text generation capability. Returns (nil, nil) if the agent doesn't support text generation. -func GenerateTrailTitle(ctx context.Context, transcriptBytes []byte, filesTouched []string, agentType types.AgentType) (*TrailTitleResult, error) { - // Get the active agent and check if it implements TextGenerator - ag, err := agent.GetByAgentType(agentType) - if err != nil { - return nil, fmt.Errorf("agent not found: %w", err) - } - gen, ok := ag.(agent.TextGenerator) - if !ok { - // Agent does not support text generation: treat as non-fatal and return no result. - return nil, nil //nolint:nilnil // nil result signals "not supported", not an error - } - - // Build condensed transcript (reuse existing infrastructure) - condensed, err := BuildCondensedTranscriptFromBytes(transcriptBytes, agentType) - if err != nil { - return nil, fmt.Errorf("failed to parse transcript: %w", err) - } - if len(condensed) == 0 { - return nil, errors.New("transcript has no content") - } - - input := Input{Transcript: condensed, FilesTouched: filesTouched} - transcriptText := FormatCondensedTranscript(input) - - // Build prompt and call agent's TextGenerator - prompt := fmt.Sprintf(trailTitlePromptTemplate, transcriptText) - rawResult, err := gen.GenerateText(ctx, prompt, trailTitleModel) - if err != nil { - return nil, fmt.Errorf("text generation failed: %w", err) - } - - // Parse JSON response (handle markdown code blocks) - cleaned := extractJSONFromMarkdown(rawResult) - var result TrailTitleResult - if err := json.Unmarshal([]byte(cleaned), &result); err != nil { - return nil, fmt.Errorf("failed to parse trail title JSON: %w", err) - } - - return &result, nil -} From dd382d3c4d8debb5652eb914f7baa4eacd34a18d Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Tue, 3 Mar 2026 09:39:59 +0100 Subject: [PATCH 22/24] Rename trails branch from entire/trails to entire/trails/v1 Aligns with the existing entire/checkpoints/v1 naming convention. Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 3282b360fb42 --- cmd/entire/cli/paths/paths.go | 2 +- cmd/entire/cli/strategy/push_common.go | 2 +- cmd/entire/cli/trail/store.go | 14 +++++++------- cmd/entire/cli/trail/trail.go | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 80d6b4827..355b689a9 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -38,7 +38,7 @@ const MetadataBranchName = "entire/checkpoints/v1" // TrailsBranchName is the orphan branch used to store trail metadata. // Trails are branch-centric work tracking abstractions that link to checkpoints by branch name. -const TrailsBranchName = "entire/trails" +const TrailsBranchName = "entire/trails/v1" // CheckpointPath returns the sharded storage path for a checkpoint ID. // Uses first 2 characters as shard (256 buckets), remaining as folder name. diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 238a0f4b4..342527521 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -208,7 +208,7 @@ func fetchAndMergeSessionsCommon(ctx context.Context, remote, branchName string) return nil } -// PushTrailsBranch pushes the entire/trails branch to the remote. +// PushTrailsBranch pushes the entire/trails/v1 branch to the remote. // Trails are always pushed regardless of the push_sessions setting. func PushTrailsBranch(ctx context.Context, remote string) error { return pushBranchIfNeeded(ctx, remote, paths.TrailsBranchName) diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go index f5e93bc16..caf25e2b5 100644 --- a/cmd/entire/cli/trail/store.go +++ b/cmd/entire/cli/trail/store.go @@ -25,7 +25,7 @@ const ( // ErrTrailNotFound is returned when a trail cannot be found. var ErrTrailNotFound = errors.New("trail not found") -// Store provides CRUD operations for trail metadata on the entire/trails branch. +// Store provides CRUD operations for trail metadata on the entire/trails/v1 branch. type Store struct { repo *git.Repository } @@ -35,7 +35,7 @@ func NewStore(repo *git.Repository) *Store { return &Store{repo: repo} } -// EnsureBranch creates the entire/trails orphan branch if it doesn't exist. +// EnsureBranch creates the entire/trails/v1 orphan branch if it doesn't exist. func (s *Store) EnsureBranch() error { refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) _, err := s.repo.Reference(refName, true) @@ -62,7 +62,7 @@ func (s *Store) EnsureBranch() error { return nil } -// Write writes trail metadata, discussion, and checkpoints to the entire/trails branch. +// Write writes trail metadata, discussion, and checkpoints to the entire/trails/v1 branch. // If checkpoints is nil, an empty checkpoints list is written. func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error { if metadata.TrailID.IsEmpty() { @@ -155,7 +155,7 @@ func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *C return nil } -// Read reads a trail by its ID from the entire/trails branch. +// Read reads a trail by its ID from the entire/trails/v1 branch. func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { tree, err := s.getBranchTree() if err != nil { @@ -233,7 +233,7 @@ func (s *Store) FindByBranch(branchName string) (*Metadata, error) { return nil, nil //nolint:nilnil // nil, nil means "not found" — callers check both } -// List returns all trail metadata from the entire/trails branch. +// List returns all trail metadata from the entire/trails/v1 branch. func (s *Store) List() ([]*Metadata, error) { tree, err := s.getBranchTree() if err != nil { @@ -309,7 +309,7 @@ func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { return s.Write(metadata, discussion, checkpoints) } -// Delete removes a trail from the entire/trails branch. +// Delete removes a trail from the entire/trails/v1 branch. func (s *Store) Delete(trailID ID) error { ref, entries, err := s.getBranchEntries() if err != nil { @@ -351,7 +351,7 @@ func (s *Store) Delete(trailID ID) error { return nil } -// getBranchTree returns the tree for the entire/trails branch HEAD. +// getBranchTree returns the tree for the entire/trails/v1 branch HEAD. func (s *Store) getBranchTree() (*object.Tree, error) { refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) ref, err := s.repo.Reference(refName, true) diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go index 4a560f595..ab1f887b6 100644 --- a/cmd/entire/cli/trail/trail.go +++ b/cmd/entire/cli/trail/trail.go @@ -1,6 +1,6 @@ // Package trail provides types and helpers for managing trail metadata. // Trails are branch-centric work tracking abstractions stored on the -// entire/trails orphan branch. They answer "why/what" (human intent) +// entire/trails/v1 orphan branch. They answer "why/what" (human intent) // while checkpoints answer "how/when" (machine snapshots). package trail From fd6ef8e23ffd85aafc5010dc1fa5aedd3dbdabc2 Mon Sep 17 00:00:00 2001 From: Daniel Adams Date: Tue, 3 Mar 2026 14:51:54 +0100 Subject: [PATCH 23/24] Align trail types with entire.io-1 web app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename StatusDone to StatusMerged ("done" → "merged") - Add Priority, Type, Reviewer types and fields to Metadata - Add resolution fields (resolved, resolved_by, resolved_at) to Comment - Fix JSON serialization: MergedAt and Summary use null instead of omitempty Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 25b31766dc0b --- .../cli/strategy/manual_commit_hooks.go | 5 +- cmd/entire/cli/trail/trail.go | 61 ++++++++++++++++--- cmd/entire/cli/trail/trail_test.go | 4 +- cmd/entire/cli/trail_cmd.go | 8 +-- 4 files changed, 61 insertions(+), 17 deletions(-) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 2f26bd39f..3fb823232 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2245,9 +2245,10 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( // appendCheckpointToExistingTrail links a checkpoint to the given trail. // Best-effort: silently returns on any error (trails are non-critical metadata). func appendCheckpointToExistingTrail(store *trail.Store, trailID trail.ID, cpID id.CheckpointID, commitSHA plumbing.Hash, prompts []string) { - var summary string + var summary *string if len(prompts) > 0 { - summary = truncateForSummary(prompts[len(prompts)-1], 200) + s := truncateForSummary(prompts[len(prompts)-1], 200) + summary = &s } //nolint:errcheck,gosec // best-effort: trail checkpoint linking is non-critical diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go index ab1f887b6..ed4133eb5 100644 --- a/cmd/entire/cli/trail/trail.go +++ b/cmd/entire/cli/trail/trail.go @@ -69,7 +69,7 @@ const ( StatusOpen Status = "open" StatusInProgress Status = "in_progress" StatusInReview Status = "in_review" - StatusDone Status = "done" + StatusMerged Status = "merged" StatusClosed Status = "closed" ) @@ -80,7 +80,7 @@ func ValidStatuses() []Status { StatusOpen, StatusInProgress, StatusInReview, - StatusDone, + StatusMerged, StatusClosed, } } @@ -95,6 +95,43 @@ func (s Status) IsValid() bool { return false } +// Priority represents the priority level of a trail. +type Priority string + +const ( + PriorityUrgent Priority = "urgent" + PriorityHigh Priority = "high" + PriorityMedium Priority = "medium" + PriorityLow Priority = "low" + PriorityNone Priority = "none" +) + +// Type represents the type/category of a trail. +type Type string + +const ( + TypeBug Type = "bug" + TypeFeature Type = "feature" + TypeChore Type = "chore" + TypeDocs Type = "docs" + TypeRefactor Type = "refactor" +) + +// ReviewerStatus represents the review status for a reviewer. +type ReviewerStatus string + +const ( + ReviewerPending ReviewerStatus = "pending" + ReviewerApproved ReviewerStatus = "approved" + ReviewerChangesRequested ReviewerStatus = "changes_requested" +) + +// Reviewer represents a reviewer assigned to a trail. +type Reviewer struct { + Login string `json:"login"` + Status ReviewerStatus `json:"status"` +} + // Metadata represents the metadata for a trail, matching the web PR format. type Metadata struct { TrailID ID `json:"trail_id"` @@ -108,7 +145,10 @@ type Metadata struct { Labels []string `json:"labels"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - MergedAt *time.Time `json:"merged_at,omitempty"` + MergedAt *time.Time `json:"merged_at"` + Priority Priority `json:"priority,omitempty"` + Type Type `json:"type,omitempty"` + Reviewers []Reviewer `json:"reviewers,omitempty"` } // Discussion holds the discussion/comments for a trail. @@ -118,11 +158,14 @@ type Discussion struct { // Comment represents a single comment on a trail. type Comment struct { - ID string `json:"id"` - Author string `json:"author"` - Body string `json:"body"` - CreatedAt time.Time `json:"created_at"` - Replies []CommentReply `json:"replies,omitempty"` + ID string `json:"id"` + Author string `json:"author"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + Resolved bool `json:"resolved"` + ResolvedBy *string `json:"resolved_by"` + ResolvedAt *time.Time `json:"resolved_at"` + Replies []CommentReply `json:"replies,omitempty"` } // CommentReply represents a reply to a comment. @@ -148,7 +191,7 @@ type CheckpointRef struct { CheckpointID string `json:"checkpoint_id"` CommitSHA string `json:"commit_sha"` CreatedAt time.Time `json:"created_at"` - Summary string `json:"summary,omitempty"` + Summary *string `json:"summary"` } // Checkpoints holds the list of checkpoint references for a trail. diff --git a/cmd/entire/cli/trail/trail_test.go b/cmd/entire/cli/trail/trail_test.go index 45e0e3ed8..7174a17cc 100644 --- a/cmd/entire/cli/trail/trail_test.go +++ b/cmd/entire/cli/trail/trail_test.go @@ -108,7 +108,7 @@ func TestStatus_IsValid(t *testing.T) { {StatusOpen, true}, {StatusInProgress, true}, {StatusInReview, true}, - {StatusDone, true}, + {StatusMerged, true}, {StatusClosed, true}, {"invalid", false}, {"", false}, @@ -132,7 +132,7 @@ func TestValidStatuses(t *testing.T) { t.Errorf("expected 6 statuses, got %d", len(statuses)) } // Verify lifecycle order - expected := []Status{StatusDraft, StatusOpen, StatusInProgress, StatusInReview, StatusDone, StatusClosed} + expected := []Status{StatusDraft, StatusOpen, StatusInProgress, StatusInReview, StatusMerged, StatusClosed} for i, s := range expected { if statuses[i] != s { t.Errorf("status[%d] = %q, want %q", i, statuses[i], s) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index cd0b341b4..383719daa 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -100,7 +100,7 @@ func newTrailListCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&statusFilter, "status", "", "Filter by status (draft, open, in_progress, in_review, done, closed)") + cmd.Flags().StringVar(&statusFilter, "status", "", "Filter by status (draft, open, in_progress, in_review, merged, closed)") cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") return cmd @@ -389,11 +389,11 @@ func runTrailUpdate(w io.Writer, statusStr, title, body, branch string, labelAdd noFlags := statusStr == "" && title == "" && body == "" && labelAdd == nil && labelRemove == nil if noFlags { // Build status options with current value as default. - // Exclude "done" and "closed" unless the trail is already in that status + // Exclude "merged" and "closed" unless the trail is already in that status // (otherwise the select would silently reset to the first option). var statusOptions []huh.Option[string] for _, s := range trail.ValidStatuses() { - if (s == trail.StatusDone || s == trail.StatusClosed) && s != metadata.Status { + if (s == trail.StatusMerged || s == trail.StatusClosed) && s != metadata.Status { continue } label := string(s) @@ -502,7 +502,7 @@ func runTrailCreateInteractive(title, body, branch, statusStr *string) error { // Build status options, excluding done/closed var statusOptions []huh.Option[string] for _, s := range trail.ValidStatuses() { - if s == trail.StatusDone || s == trail.StatusClosed { + if s == trail.StatusMerged || s == trail.StatusClosed { continue } statusOptions = append(statusOptions, huh.NewOption(string(s), string(s))) From bf021d4f3413ad23b9469c71ad3b3fa90be42328 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Tue, 3 Mar 2026 16:39:49 +0100 Subject: [PATCH 24/24] refactoring to be in line with latest entire/checkpoints/v1 branch handling Entire-Checkpoint: df64be0355a1 --- cmd/entire/cli/checkpoint/committed.go | 34 +++ cmd/entire/cli/checkpoint/temporary.go | 31 +-- cmd/entire/cli/trail/store.go | 330 ++++++++++++++----------- cmd/entire/cli/trail/store_test.go | 146 +++++++++++ cmd/entire/cli/trail/trail.go | 9 + 5 files changed, 379 insertions(+), 171 deletions(-) diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index ecc908a83..0939010d2 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -1526,6 +1526,40 @@ func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) { return name, email } +// CreateCommit creates a git commit object with the given tree, parent, message, and author. +// If parentHash is ZeroHash, the commit is created without a parent (orphan commit). +func CreateCommit(repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { + now := time.Now() + sig := object.Signature{ + Name: authorName, + Email: authorEmail, + When: now, + } + + commit := &object.Commit{ + TreeHash: treeHash, + Author: sig, + Committer: sig, + Message: message, + } + + if parentHash != plumbing.ZeroHash { + commit.ParentHashes = []plumbing.Hash{parentHash} + } + + obj := repo.Storer.NewEncodedObject() + if err := commit.Encode(obj); err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) + } + + hash, err := repo.Storer.SetEncodedObject(obj) + if err != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) + } + + return hash, nil +} + // readTranscriptFromTree reads a transcript from a git tree, handling both chunked and non-chunked formats. // It checks for chunk files first (.001, .002, etc.), then falls back to the base file. // The agentType is used for reassembling chunks in the correct format. diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index f066f1024..15543d05e 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -764,36 +764,7 @@ func (s *GitStore) buildTreeWithChanges( // createCommit creates a commit object. func (s *GitStore) createCommit(treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { - now := time.Now() - sig := object.Signature{ - Name: authorName, - Email: authorEmail, - When: now, - } - - commit := &object.Commit{ - TreeHash: treeHash, - Author: sig, - Committer: sig, - Message: message, - } - - // Add parent if not a new branch - if parentHash != plumbing.ZeroHash { - commit.ParentHashes = []plumbing.Hash{parentHash} - } - - obj := s.repo.Storer.NewEncodedObject() - if err := commit.Encode(obj); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) - } - - hash, err := s.repo.Storer.SetEncodedObject(obj) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) - } - - return hash, nil + return CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) } // Helper functions extracted from strategy/common.go diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go index caf25e2b5..1e6e6f555 100644 --- a/cmd/entire/cli/trail/store.go +++ b/cmd/entire/cli/trail/store.go @@ -50,7 +50,7 @@ func (s *Store) EnsureBranch() error { } authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) - commitHash, err := s.createCommit(emptyTreeHash, plumbing.ZeroHash, "Initialize trails branch", authorName, authorEmail) + commitHash, err := checkpoint.CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize trails branch", authorName, authorEmail) if err != nil { return fmt.Errorf("failed to create initial commit: %w", err) } @@ -73,90 +73,78 @@ func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *C return fmt.Errorf("failed to ensure trails branch: %w", err) } - // Get current branch tree - ref, entries, err := s.getBranchEntries() + commitHash, rootTreeHash, err := s.getBranchRef() if err != nil { - return fmt.Errorf("failed to get branch entries: %w", err) + return fmt.Errorf("failed to get branch ref: %w", err) } - // Build sharded path - basePath := metadata.TrailID.Path() + "/" - - // Create metadata blob - metadataJSON, err := json.MarshalIndent(metadata, "", " ") + // Build blob entries for the trail's 3 files + trailEntries, err := s.buildTrailEntries(metadata, discussion, checkpoints) if err != nil { - return fmt.Errorf("failed to marshal metadata: %w", err) + return err } - metadataBlob, err := checkpoint.CreateBlobFromContent(s.repo, metadataJSON) + + // Splice into tree at [shard, suffix] — preserves sibling trails automatically + shard, suffix := metadata.TrailID.ShardParts() + newTreeHash, err := checkpoint.UpdateSubtree( + s.repo, rootTreeHash, + []string{shard, suffix}, + trailEntries, + checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.ReplaceAll}, + ) if err != nil { - return fmt.Errorf("failed to create metadata blob: %w", err) - } - entries[basePath+metadataFile] = object.TreeEntry{ - Name: basePath + metadataFile, - Mode: filemode.Regular, - Hash: metadataBlob, + return fmt.Errorf("failed to update subtree: %w", err) } - // Create discussion blob + commitMsg := fmt.Sprintf("Trail: %s (%s)", metadata.Title, metadata.TrailID) + return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) +} + +// buildTrailEntries creates blob objects for a trail's 3 files and returns them as tree entries. +func (s *Store) buildTrailEntries(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) ([]object.TreeEntry, error) { if discussion == nil { discussion = &Discussion{Comments: []Comment{}} } - discussionJSON, err := json.MarshalIndent(discussion, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal discussion: %w", err) - } - discussionBlob, err := checkpoint.CreateBlobFromContent(s.repo, discussionJSON) - if err != nil { - return fmt.Errorf("failed to create discussion blob: %w", err) - } - entries[basePath+discussionFile] = object.TreeEntry{ - Name: basePath + discussionFile, - Mode: filemode.Regular, - Hash: discussionBlob, - } - - // Create checkpoints blob if checkpoints == nil { checkpoints = &Checkpoints{Checkpoints: []CheckpointRef{}} } - checkpointsJSON, err := json.MarshalIndent(checkpoints, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal checkpoints: %w", err) - } - checkpointsBlob, err := checkpoint.CreateBlobFromContent(s.repo, checkpointsJSON) - if err != nil { - return fmt.Errorf("failed to create checkpoints blob: %w", err) - } - entries[basePath+checkpointsFile] = object.TreeEntry{ - Name: basePath + checkpointsFile, - Mode: filemode.Regular, - Hash: checkpointsBlob, - } - // Build tree and commit - newTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, entries) - if err != nil { - return fmt.Errorf("failed to build tree: %w", err) + type fileSpec struct { + name string + data any } - - authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) - commitMsg := fmt.Sprintf("Trail: %s (%s)", metadata.Title, metadata.TrailID) - commitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) - if err != nil { - return fmt.Errorf("failed to create commit: %w", err) + files := []fileSpec{ + {metadataFile, metadata}, + {discussionFile, discussion}, + {checkpointsFile, checkpoints}, } - // Update branch ref - newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.TrailsBranchName), commitHash) - if err := s.repo.Storer.SetReference(newRef); err != nil { - return fmt.Errorf("failed to update branch reference: %w", err) + entries := make([]object.TreeEntry, 0, len(files)) + for _, f := range files { + jsonBytes, err := json.MarshalIndent(f.data, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal %s: %w", f.name, err) + } + blobHash, err := checkpoint.CreateBlobFromContent(s.repo, jsonBytes) + if err != nil { + return nil, fmt.Errorf("failed to create %s blob: %w", f.name, err) + } + entries = append(entries, object.TreeEntry{ + Name: f.name, + Mode: filemode.Regular, + Hash: blobHash, + }) } - return nil + return entries, nil } // Read reads a trail by its ID from the entire/trails/v1 branch. func (s *Store) Read(trailID ID) (*Metadata, *Discussion, *Checkpoints, error) { + if err := ValidateID(string(trailID)); err != nil { + return nil, nil, nil, err + } + tree, err := s.getBranchTree() if err != nil { return nil, nil, nil, err @@ -278,6 +266,7 @@ func (s *Store) List() ([]*Metadata, error) { // Update updates an existing trail's metadata. It reads the current metadata, // applies the provided update function, and writes it back. func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { + // ValidateID is called by Read, no need to duplicate here metadata, discussion, checkpoints, err := s.Read(trailID) if err != nil { return fmt.Errorf("failed to read trail for update: %w", err) @@ -290,148 +279,207 @@ func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { } // AddCheckpoint prepends a checkpoint reference to a trail's checkpoints list (newest first). +// Only reads and writes the checkpoints.json file — metadata and discussion are untouched. func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { - metadata, discussion, checkpoints, err := s.Read(trailID) + if err := ValidateID(string(trailID)); err != nil { + return err + } + + if err := s.EnsureBranch(); err != nil { + return fmt.Errorf("failed to ensure trails branch: %w", err) + } + + commitHash, rootTreeHash, err := s.getBranchRef() if err != nil { - return fmt.Errorf("failed to read trail for checkpoint update: %w", err) + return fmt.Errorf("failed to get branch ref: %w", err) } - if checkpoints == nil { - checkpoints = &Checkpoints{Checkpoints: []CheckpointRef{}} + // Navigate to the trail's subtree and read only checkpoints.json + shard, suffix := trailID.ShardParts() + trailTree, err := s.navigateToTrailTree(rootTreeHash, shard, suffix) + if err != nil { + return fmt.Errorf("failed to read checkpoints for trail %s: %w", trailID, err) + } + + checkpoints, err := s.readCheckpointsFromTrailTree(trailTree) + if err != nil { + return fmt.Errorf("failed to read checkpoints for trail %s: %w", trailID, err) } - // Prepend new ref (newest first) without always allocating a new slice. - // Grow the slice by one, shift existing elements right, and insert at index 0. + // Prepend new ref (newest first) checkpoints.Checkpoints = append(checkpoints.Checkpoints, CheckpointRef{}) copy(checkpoints.Checkpoints[1:], checkpoints.Checkpoints[:len(checkpoints.Checkpoints)-1]) checkpoints.Checkpoints[0] = ref - return s.Write(metadata, discussion, checkpoints) + // Create new blob and splice back — MergeKeepExisting preserves metadata.json and discussion.json + checkpointsJSON, err := json.MarshalIndent(checkpoints, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal checkpoints: %w", err) + } + blobHash, err := checkpoint.CreateBlobFromContent(s.repo, checkpointsJSON) + if err != nil { + return fmt.Errorf("failed to create checkpoints blob: %w", err) + } + + newTreeHash, err := checkpoint.UpdateSubtree( + s.repo, rootTreeHash, + []string{shard, suffix}, + []object.TreeEntry{{Name: checkpointsFile, Mode: filemode.Regular, Hash: blobHash}}, + checkpoint.UpdateSubtreeOptions{MergeMode: checkpoint.MergeKeepExisting}, + ) + if err != nil { + return fmt.Errorf("failed to update subtree: %w", err) + } + + commitMsg := fmt.Sprintf("Add checkpoint to trail: %s", trailID) + return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) } // Delete removes a trail from the entire/trails/v1 branch. func (s *Store) Delete(trailID ID) error { - ref, entries, err := s.getBranchEntries() - if err != nil { - return fmt.Errorf("failed to get branch entries: %w", err) + if err := ValidateID(string(trailID)); err != nil { + return err } - basePath := trailID.Path() + "/" + if err := s.EnsureBranch(); err != nil { + return fmt.Errorf("failed to ensure trails branch: %w", err) + } - // Remove entries for this trail - found := false - for path := range entries { - if strings.HasPrefix(path, basePath) { - delete(entries, path) - found = true - } + commitHash, rootTreeHash, err := s.getBranchRef() + if err != nil { + return fmt.Errorf("failed to get branch ref: %w", err) } - if !found { - return fmt.Errorf("trail %s not found", trailID) + + // Verify the trail exists by navigating the tree at O(depth) + shard, suffix := trailID.ShardParts() + if _, err := s.navigateToTrailTree(rootTreeHash, shard, suffix); err != nil { + return err } - // Build tree and commit - newTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, entries) + // Delete the trail's subtree by removing it from the shard directory + newTreeHash, err := checkpoint.UpdateSubtree( + s.repo, rootTreeHash, + []string{shard}, + nil, + checkpoint.UpdateSubtreeOptions{ + MergeMode: checkpoint.MergeKeepExisting, + DeleteNames: []string{suffix}, + }, + ) if err != nil { - return fmt.Errorf("failed to build tree: %w", err) + return fmt.Errorf("failed to update subtree: %w", err) } - authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Delete trail: %s", trailID) - commitHash, err := s.createCommit(newTreeHash, ref.Hash(), commitMsg, authorName, authorEmail) + return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) +} + +// navigateToTrailTree walks rootTree → shard → suffix and returns the trail's subtree. +func (s *Store) navigateToTrailTree(rootTreeHash plumbing.Hash, shard, suffix string) (*object.Tree, error) { + rootTree, err := s.repo.TreeObject(rootTreeHash) if err != nil { - return fmt.Errorf("failed to create commit: %w", err) + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) } - newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.TrailsBranchName), commitHash) - if err := s.repo.Storer.SetReference(newRef); err != nil { - return fmt.Errorf("failed to update branch reference: %w", err) + shardEntry, err := rootTree.FindEntry(shard) + if err != nil { + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) } - return nil -} - -// getBranchTree returns the tree for the entire/trails/v1 branch HEAD. -func (s *Store) getBranchTree() (*object.Tree, error) { - refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) - ref, err := s.repo.Reference(refName, true) + shardTree, err := s.repo.TreeObject(shardEntry.Hash) if err != nil { - // Try remote tracking branch - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.TrailsBranchName) - ref, err = s.repo.Reference(remoteRefName, true) - if err != nil { - return nil, fmt.Errorf("trails branch not found: %w", err) - } + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) } - commit, err := s.repo.CommitObject(ref.Hash()) + trailEntry, err := shardTree.FindEntry(suffix) if err != nil { - return nil, fmt.Errorf("failed to get commit: %w", err) + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) } - tree, err := commit.Tree() + trailTree, err := s.repo.TreeObject(trailEntry.Hash) if err != nil { - return nil, fmt.Errorf("failed to get tree: %w", err) + return nil, fmt.Errorf("trail %s/%s not found: %w", shard, suffix, err) } - return tree, nil + return trailTree, nil } -// getBranchEntries returns the current branch reference and a flat map of all tree entries. -func (s *Store) getBranchEntries() (*plumbing.Reference, map[string]object.TreeEntry, error) { - refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) - ref, err := s.repo.Reference(refName, true) +// readCheckpointsFromTrailTree reads checkpoints.json from a trail's subtree. +// Returns empty checkpoints if the file doesn't exist yet. +func (s *Store) readCheckpointsFromTrailTree(trailTree *object.Tree) (*Checkpoints, error) { + cpEntry, err := trailTree.FindEntry(checkpointsFile) if err != nil { - return nil, nil, fmt.Errorf("trails branch not found: %w", err) + // No checkpoints file yet — return empty + return &Checkpoints{Checkpoints: []CheckpointRef{}}, nil } - commit, err := s.repo.CommitObject(ref.Hash()) + blob, err := s.repo.BlobObject(cpEntry.Hash) if err != nil { - return nil, nil, fmt.Errorf("failed to get commit: %w", err) + return nil, fmt.Errorf("failed to read checkpoints blob: %w", err) } - tree, err := commit.Tree() + reader, err := blob.Reader() if err != nil { - return nil, nil, fmt.Errorf("failed to get tree: %w", err) + return nil, fmt.Errorf("failed to open checkpoints reader: %w", err) } + defer reader.Close() - entries := make(map[string]object.TreeEntry) - if err := checkpoint.FlattenTree(s.repo, tree, "", entries); err != nil { - return nil, nil, fmt.Errorf("failed to flatten tree: %w", err) + var checkpoints Checkpoints + if err := json.NewDecoder(reader).Decode(&checkpoints); err != nil { + return nil, fmt.Errorf("failed to decode checkpoints: %w", err) } - return ref, entries, nil + return &checkpoints, nil } -// createCommit creates a commit on the trails branch. -func (s *Store) createCommit(treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { - now := time.Now() - sig := object.Signature{ - Name: authorName, - Email: authorEmail, - When: now, +// commitAndUpdateRef creates a commit and updates the trails branch reference. +func (s *Store) commitAndUpdateRef(treeHash, parentHash plumbing.Hash, message string) error { + authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) + commitHash, err := checkpoint.CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) + if err != nil { + return fmt.Errorf("failed to create commit: %w", err) } - commit := &object.Commit{ - TreeHash: treeHash, - Author: sig, - Committer: sig, - Message: message, + newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.TrailsBranchName), commitHash) + if err := s.repo.Storer.SetReference(newRef); err != nil { + return fmt.Errorf("failed to update branch reference: %w", err) + } + return nil +} + +// getBranchRef returns the commit hash and root tree hash for the entire/trails/v1 branch HEAD +// without flattening the tree. Falls back to remote tracking branch if local is missing. +func (s *Store) getBranchRef() (commitHash, rootTreeHash plumbing.Hash, err error) { + refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) + ref, refErr := s.repo.Reference(refName, true) + if refErr != nil { + // Try remote tracking branch + remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.TrailsBranchName) + ref, refErr = s.repo.Reference(remoteRefName, true) + if refErr != nil { + return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("trails branch not found: %w", refErr) + } } - if parentHash != plumbing.ZeroHash { - commit.ParentHashes = []plumbing.Hash{parentHash} + commit, err := s.repo.CommitObject(ref.Hash()) + if err != nil { + return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("failed to get commit: %w", err) } - obj := s.repo.Storer.NewEncodedObject() - if err := commit.Encode(obj); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) + return ref.Hash(), commit.TreeHash, nil +} + +// getBranchTree returns the tree for the entire/trails/v1 branch HEAD. +func (s *Store) getBranchTree() (*object.Tree, error) { + _, rootTreeHash, err := s.getBranchRef() + if err != nil { + return nil, err } - hash, err := s.repo.Storer.SetEncodedObject(obj) + tree, err := s.repo.TreeObject(rootTreeHash) if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) + return nil, fmt.Errorf("failed to get tree: %w", err) } - return hash, nil + return tree, nil } diff --git a/cmd/entire/cli/trail/store_test.go b/cmd/entire/cli/trail/store_test.go index 79799ca38..f0fbca687 100644 --- a/cmd/entire/cli/trail/store_test.go +++ b/cmd/entire/cli/trail/store_test.go @@ -331,3 +331,149 @@ func TestStore_ReadNonExistent(t *testing.T) { t.Error("Read() should fail for non-existent trail") } } + +func TestStore_ReadInvalidID(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // Invalid format: too short + _, _, _, err := store.Read(ID("abc")) + if err == nil { + t.Error("Read() should fail for invalid trail ID") + } + + // Path traversal attempt + _, _, _, err = store.Read(ID("../../etc/pass")) + if err == nil { + t.Error("Read() should fail for path traversal ID") + } +} + +func TestStore_DeleteInvalidID(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + // Invalid format: uppercase hex + err := store.Delete(ID("ABCDEF123456")) + if err == nil { + t.Error("Delete() should fail for invalid trail ID") + } + + // Path traversal attempt + err = store.Delete(ID("../../../etc")) + if err == nil { + t.Error("Delete() should fail for path traversal ID") + } +} + +func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { + t.Parallel() + repo := initTestRepo(t) + store := NewStore(repo) + + trailID, err := GenerateID() + if err != nil { + t.Fatalf("GenerateID() error = %v", err) + } + + now := time.Now().Truncate(time.Second) + metadata := &Metadata{ + TrailID: trailID, + Branch: "feature/preserve", + Base: "main", + Title: "Preservation test", + Body: "Verify AddCheckpoint doesn't corrupt other fields", + Status: StatusInProgress, + Author: "tester", + Assignees: []string{"alice"}, + Labels: []string{"important"}, + CreatedAt: now, + UpdatedAt: now, + } + discussion := &Discussion{Comments: []Comment{ + {ID: "c1", Author: "bob", Body: "looks good", CreatedAt: now}, + }} + + if err := store.Write(metadata, discussion, nil); err != nil { + t.Fatalf("Write() error = %v", err) + } + + // Add a checkpoint + firstSummary := "first checkpoint" + cpRef := CheckpointRef{ + CheckpointID: "aabbccddeeff", + CommitSHA: "deadbeef1234", + CreatedAt: now, + Summary: &firstSummary, + } + if err := store.AddCheckpoint(trailID, cpRef); err != nil { + t.Fatalf("AddCheckpoint() error = %v", err) + } + + // Read back and verify metadata + discussion are unchanged + gotMeta, gotDisc, gotCPs, err := store.Read(trailID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + + // Metadata unchanged + if gotMeta.Title != "Preservation test" { + t.Errorf("metadata title changed: got %q, want %q", gotMeta.Title, "Preservation test") + } + if gotMeta.Body != "Verify AddCheckpoint doesn't corrupt other fields" { + t.Errorf("metadata body changed: got %q", gotMeta.Body) + } + if gotMeta.Status != StatusInProgress { + t.Errorf("metadata status changed: got %q, want %q", gotMeta.Status, StatusInProgress) + } + if len(gotMeta.Assignees) != 1 || gotMeta.Assignees[0] != "alice" { + t.Errorf("metadata assignees changed: got %v", gotMeta.Assignees) + } + if len(gotMeta.Labels) != 1 || gotMeta.Labels[0] != "important" { + t.Errorf("metadata labels changed: got %v", gotMeta.Labels) + } + + // Discussion unchanged + if len(gotDisc.Comments) != 1 { + t.Fatalf("discussion comments count = %d, want 1", len(gotDisc.Comments)) + } + if gotDisc.Comments[0].ID != "c1" || gotDisc.Comments[0].Body != "looks good" { + t.Errorf("discussion comment changed: got %+v", gotDisc.Comments[0]) + } + + // Checkpoint added correctly + if len(gotCPs.Checkpoints) != 1 { + t.Fatalf("checkpoints count = %d, want 1", len(gotCPs.Checkpoints)) + } + if gotCPs.Checkpoints[0].CheckpointID != "aabbccddeeff" { + t.Errorf("checkpoint ID = %q, want %q", gotCPs.Checkpoints[0].CheckpointID, "aabbccddeeff") + } + + // Add a second checkpoint — should prepend + secondSummary := "second checkpoint" + cpRef2 := CheckpointRef{ + CheckpointID: "112233445566", + CommitSHA: "cafebabe5678", + CreatedAt: now, + Summary: &secondSummary, + } + if err := store.AddCheckpoint(trailID, cpRef2); err != nil { + t.Fatalf("AddCheckpoint() second call error = %v", err) + } + + _, _, gotCPs2, err := store.Read(trailID) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if len(gotCPs2.Checkpoints) != 2 { + t.Fatalf("checkpoints count = %d, want 2", len(gotCPs2.Checkpoints)) + } + if gotCPs2.Checkpoints[0].CheckpointID != "112233445566" { + t.Errorf("newest checkpoint should be first, got %q", gotCPs2.Checkpoints[0].CheckpointID) + } + if gotCPs2.Checkpoints[1].CheckpointID != "aabbccddeeff" { + t.Errorf("older checkpoint should be second, got %q", gotCPs2.Checkpoints[1].CheckpointID) + } +} diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go index ed4133eb5..9b3eb672d 100644 --- a/cmd/entire/cli/trail/trail.go +++ b/cmd/entire/cli/trail/trail.go @@ -61,6 +61,15 @@ func (id ID) Path() string { return string(id[:2]) + "/" + string(id[2:]) } +// ShardParts returns the shard prefix and suffix separately. +// Example: "a3b2c4d5e6f7" -> ("a3", "b2c4d5e6f7") +func (id ID) ShardParts() (shard, suffix string) { + if len(id) < 3 { + return string(id), "" + } + return string(id[:2]), string(id[2:]) +} + // Status represents the lifecycle status of a trail. type Status string