fix table rendering
docs: epic/subtask ordering guidance for agents
5 files changed,  +142, -57
M AGENTS.md
+2, -0
1@@ -1,5 +1,7 @@
2 This is an alternative CLI for the [git-bug](https://github.com/git-bug/git-bug) library. Invoke REQUIRED SKILL **superpowers** when working on features in this application.
3 
4+This is a Jujutsu-managed repository. Agents must use `jj` commands (`jj st`, `jj diff`, `jj new`, `jj commit`, `jj describe`, `jj abandon`, etc.) to inspect history and to commit or alter repository state. Do NOT use `git commit`, `git stash`, `git worktree`, or other git commands that move HEAD, create commits, or rewrite refs: bypassing `jj` forks the change graph and orphans jj's working-copy snapshots. Read-only git commands (`git show <sha>`, `git diff <a> <b>`, `git log`) are acceptable for inspection only.
5+
6 The `bug` application uses Cobra for handling subcommands. Always recommend the minimum nesting of subcommands to accomplish the goal so that the API is easier for users to understand.  **GOOD**: `bug new`.  **BAD**: `bug issue new` = too many levels of subcommands.
7 
8 Use TDD. Break up large files into smaller, cohesive units that implement an operation, i.e., one file for each subcommand, with shared functionality in a separate file that deals with a particular entity type, such as an issue, comment, or identity.
M README.md
+3, -3
 1@@ -33,8 +33,8 @@ bug list --filter age:>30d               # Show issues older than 30 days
 2 ```bash
 3 bug list --sort id:asc                   # Sort by ID ascending
 4 bug list --sort id:desc                  # Sort by ID descending
 5-bug list --sort age:asc                  # Sort by age, oldest first
 6-bug list --sort age:desc                 # Sort by age, newest first (default)
 7+bug list --sort age:asc                  # Sort by age, newest first
 8+bug list --sort age:desc                 # Sort by age, oldest first (default)
 9 ```
10 
11 **Status:**
12@@ -45,7 +45,7 @@ bug list --status all                    # Show all issues
13 ```
14 
15 **Parent/child tree:**
16-Issues created with `--parent` are nested under their parent with tree markers (`├─`, `└─`). Parents sort newest first, with each parent's children nested below it newest first. Passing `--sort` forces a flat table with that sort order instead.
17+Issues created with `--parent` are nested under their parent with tree markers (`├─`, `└─`). Parents sort oldest first, with each parent's children nested below it oldest first. Passing `--sort` forces a flat table with that sort order instead.
18 
19 ### Create a New Issue
20 
M SKILL.md
+29, -0
 1@@ -309,6 +309,35 @@ New findings:
 2 EOF
 3 ```
 4 
 5+### Tracking Epics and Subtasks with Parent Links
 6+
 7+Use `--parent` to group subtasks under a parent epic or tracking issue. In `bug ls`, children nest under their parent with tree markers (`├─`, `└─`).
 8+
 9+**Default sort is oldest first:** within a parent, subtasks display top-to-bottom in creation order (oldest first, youngest last). Creation order therefore doubles as priority order when the user plans work with `bug ls`.
10+
11+**Recommended flow — parent first:**
12+1. Create the parent epic first:
13+   ```bash
14+   bug agent new --title "Epic: checkout rewrite" --message "..."
15+   ```
16+2. Create each subtask in the order it should be addressed, highest priority first:
17+   ```bash
18+   bug agent new --title "Step 1: ..." --message "..." --parent <epicID>
19+   bug agent new --title "Step 2: ..." --message "..." --parent <epicID>
20+   ```
21+3. `bug ls` now lists Step 1 above Step 2 under the epic, so the user reads the plan top-down.
22+
23+**Alternative flow — children first:**
24+1. Create the subtasks in the order they should be addressed.
25+2. Create the parent epic afterwards.
26+3. Link each child to the parent:
27+   ```bash
28+   bug agent edit <childID> --parent <epicID>
29+   ```
30+Because display order follows creation order, the first-created subtask still appears first.
31+
32+**Limits:** a parent link can only be added when the issue has none; links cannot be changed or removed afterwards, and links that would close a dependency cycle are rejected.
33+
34 ### bug agent open [bugID]
35 
36 Open a closed bug/issue.
M main.go
+50, -39
  1@@ -10,6 +10,7 @@ import (
  2 	"sort"
  3 	"strings"
  4 	"time"
  5+	"unicode/utf8"
  6 
  7 	"github.com/charmbracelet/lipgloss"
  8 	"github.com/dustin/go-humanize"
  9@@ -228,7 +229,7 @@ type SortSpec struct {
 10 // ParseSort parses a sort string like "id:asc" or "age:desc"
 11 func ParseSort(sortStr string) (*SortSpec, error) {
 12 	if sortStr == "" {
 13-		// Default: age:desc (newest first)
 14+		// Default: age:desc (oldest first)
 15 		return &SortSpec{Field: "age", Direction: "desc"}, nil
 16 	}
 17 
 18@@ -264,7 +265,8 @@ func ApplySort(issues []BugIssue, spec *SortSpec) {
 19 		case "id":
 20 			less = issues[i].FullID < issues[j].FullID
 21 		case "age":
 22-			less = issues[i].CreatedAt.Before(issues[j].CreatedAt) // Older first (ascending)
 23+			// less defines ascending order; descending age means oldest first.
 24+			less = issues[i].CreatedAt.After(issues[j].CreatedAt) // Younger first (ascending)
 25 		}
 26 
 27 		if spec.Direction == "desc" {
 28@@ -342,18 +344,14 @@ func printTable(issues []BugIssue) error {
 29 	for i, issue := range issues {
 30 		// Format ID (with ANSI codes)
 31 		id := formatID(issue.FullID, issue.ShortID)
 32-		idVisualLen := len(stripANSI(id))
 33-		if idVisualLen > idWidth {
 34-			idWidth = idVisualLen
 35+		if w := visualLen(id); w > idWidth {
 36+			idWidth = w
 37 		}
 38 
 39 		// Format summary (truncate at 70 chars)
 40-		summary := issue.Title
 41-		if len(summary) > maxSummaryLen {
 42-			summary = summary[:maxSummaryLen-3] + "..."
 43-		}
 44-		if len(summary) > summaryWidth {
 45-			summaryWidth = len(summary)
 46+		summary := truncateSummary(issue.Title, maxSummaryLen)
 47+		if w := visualLen(summary); w > summaryWidth {
 48+			summaryWidth = w
 49 		}
 50 
 51 		// Format labels
 52@@ -361,14 +359,14 @@ func printTable(issues []BugIssue) error {
 53 		if labels == "" {
 54 			labels = "-"
 55 		}
 56-		if len(labels) > labelsWidth {
 57-			labelsWidth = len(labels)
 58+		if w := visualLen(labels); w > labelsWidth {
 59+			labelsWidth = w
 60 		}
 61 
 62 		// Format age
 63 		age := humanize.Time(issue.CreatedAt)
 64-		if len(age) > ageWidth {
 65-			ageWidth = len(age)
 66+		if w := visualLen(age); w > ageWidth {
 67+			ageWidth = w
 68 		}
 69 
 70 		rows[i] = rowData{id: id, summary: summary, labels: labels, age: age}
 71@@ -465,16 +463,16 @@ func buildIssueTree(issues []BugIssue) []*TreeNode {
 72 		}
 73 	}
 74 
 75-	// Sort roots by age descending (newest first)
 76+	// Sort roots by age descending (oldest first)
 77 	sort.Slice(roots, func(i, j int) bool {
 78-		return roots[i].Issue.CreatedAt.After(roots[j].Issue.CreatedAt)
 79+		return roots[i].Issue.CreatedAt.Before(roots[j].Issue.CreatedAt)
 80 	})
 81 
 82-	// Sort children of each node by age descending
 83+	// Sort children of each node by age descending (oldest first)
 84 	var sortChildren func([]*TreeNode)
 85 	sortChildren = func(nodes []*TreeNode) {
 86 		sort.Slice(nodes, func(i, j int) bool {
 87-			return nodes[i].Issue.CreatedAt.After(nodes[j].Issue.CreatedAt)
 88+			return nodes[i].Issue.CreatedAt.Before(nodes[j].Issue.CreatedAt)
 89 		})
 90 		for _, n := range nodes {
 91 			if len(n.Children) > 0 {
 92@@ -524,20 +522,17 @@ func printTreeTable(roots []*TreeNode) error {
 93 				nodePrefix = prefix + "├─ "
 94 			}
 95 
 96-			// Format ID (with ANSI codes)
 97+			// Format ID (with ANSI codes); prefix counts in runes
 98+			// since tree markers are multibyte but single-width
 99 			id := formatID(node.Issue.FullID, node.Issue.ShortID)
100-			idVisualLen := len(nodePrefix) + len(stripANSI(id))
101-			if idVisualLen > idWidth {
102-				idWidth = idVisualLen
103+			if w := visualLen(nodePrefix) + visualLen(id); w > idWidth {
104+				idWidth = w
105 			}
106 
107 			// Format summary (truncate at 70 chars)
108-			summary := node.Issue.Title
109-			if len(summary) > maxSummaryLen {
110-				summary = summary[:maxSummaryLen-3] + "..."
111-			}
112-			if len(summary) > summaryWidth {
113-				summaryWidth = len(summary)
114+			summary := truncateSummary(node.Issue.Title, maxSummaryLen)
115+			if w := visualLen(summary); w > summaryWidth {
116+				summaryWidth = w
117 			}
118 
119 			// Format labels
120@@ -545,14 +540,14 @@ func printTreeTable(roots []*TreeNode) error {
121 			if labels == "" {
122 				labels = "-"
123 			}
124-			if len(labels) > labelsWidth {
125-				labelsWidth = len(labels)
126+			if w := visualLen(labels); w > labelsWidth {
127+				labelsWidth = w
128 			}
129 
130 			// Format age
131 			age := humanize.Time(node.Issue.CreatedAt)
132-			if len(age) > ageWidth {
133-				ageWidth = len(age)
134+			if w := visualLen(age); w > ageWidth {
135+				ageWidth = w
136 			}
137 
138 			allRows = append(allRows, rowData{
139@@ -611,10 +606,26 @@ func printTreeTable(roots []*TreeNode) error {
140 	return nil
141 }
142 
143+// visualLen returns the display width of a string in columns,
144+// ignoring ANSI escape codes. Tree markers (├, └, │, ─) are
145+// single-width runes but multi-byte in UTF-8, so byte length
146+// would over-count them and misalign columns.
147+func visualLen(s string) int {
148+	return utf8.RuneCountInString(stripANSI(s))
149+}
150+
151+// truncateSummary shortens s to at most max runes, counting in runes
152+// so multibyte characters are never split mid-encoding.
153+func truncateSummary(s string, max int) string {
154+	if r := []rune(s); len(r) > max {
155+		return string(r[:max-3]) + "..."
156+	}
157+	return s
158+}
159+
160 // padRight pads a string to the specified width, accounting for ANSI codes
161 func padRight(s string, width int) string {
162-	visualLen := len(stripANSI(s))
163-	padding := width - visualLen
164+	padding := width - visualLen(s)
165 	if padding < 0 {
166 		padding = 0
167 	}
168@@ -1610,8 +1621,8 @@ var listCmd = &cobra.Command{
169 	Long: `Display a table of all git-bug issues with their ID, summary, labels, and age.
170 
171 Issues linked to a parent via --parent are nested under it with tree
172-markers (├─, └─). Parents sort newest first, with each parent's
173-children nested below it newest first. Passing --sort forces a flat
174+markers (├─, └─). Parents sort oldest first, with each parent's
175+children nested below it oldest first. Passing --sort forces a flat
176 table with that sort order instead.
177 
178 Filtering:
179@@ -1621,8 +1632,8 @@ Filtering:
180 Sorting:
181   --sort id:asc              Sort by ID (ascending)
182   --sort id:desc             Sort by ID (descending)
183-  --sort age:asc             Sort by age, oldest first
184-  --sort age:desc            Sort by age, newest first (default)
185+  --sort age:asc             Sort by age, newest first
186+  --sort age:desc            Sort by age, oldest first (default)
187 
188 Status Filtering:
189   -S, --status open          Show only open issues (default)
M tree_test.go
+58, -15
  1@@ -8,31 +8,31 @@ import (
  2 	"time"
  3 )
  4 
  5-func TestApplySort_AgeDescNewestFirst(t *testing.T) {
  6+func TestApplySort_AgeDescOldestFirst(t *testing.T) {
  7 	now := time.Now()
  8 	issues := []BugIssue{
  9-		{FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
 10 		{FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
 11+		{FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
 12 	}
 13 
 14 	ApplySort(issues, &SortSpec{Field: "age", Direction: "desc"})
 15 
 16-	if issues[0].FullID != "newer" {
 17-		t.Errorf("age:desc first = %q, want %q (newest first)", issues[0].FullID, "newer")
 18+	if issues[0].FullID != "older" {
 19+		t.Errorf("age:desc first = %q, want %q (oldest first)", issues[0].FullID, "older")
 20 	}
 21 }
 22 
 23-func TestApplySort_AgeAscOldestFirst(t *testing.T) {
 24+func TestApplySort_AgeAscNewestFirst(t *testing.T) {
 25 	now := time.Now()
 26 	issues := []BugIssue{
 27-		{FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
 28 		{FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
 29+		{FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
 30 	}
 31 
 32 	ApplySort(issues, &SortSpec{Field: "age", Direction: "asc"})
 33 
 34-	if issues[0].FullID != "older" {
 35-		t.Errorf("age:asc first = %q, want %q (oldest first)", issues[0].FullID, "older")
 36+	if issues[0].FullID != "newer" {
 37+		t.Errorf("age:asc first = %q, want %q (newest first)", issues[0].FullID, "newer")
 38 	}
 39 }
 40 
 41@@ -54,13 +54,13 @@ func TestBuildIssueTree_NestsChildrenUnderParent(t *testing.T) {
 42 	}
 43 }
 44 
 45-func TestBuildIssueTree_SortsRootsAndChildrenNewestFirst(t *testing.T) {
 46+func TestBuildIssueTree_SortsRootsAndChildrenOldestFirst(t *testing.T) {
 47 	now := time.Now()
 48 	issues := []BugIssue{
 49 		{FullID: "old-parent", ShortID: "old", Title: "Old parent", CreatedAt: now.Add(-48 * time.Hour)},
 50 		{FullID: "new-parent", ShortID: "new", Title: "New parent", CreatedAt: now.Add(-24 * time.Hour)},
 51-		{FullID: "old-child", ShortID: "oldc", Title: "Old child", CreatedAt: now.Add(-2 * time.Hour), ParentID: "new-parent"},
 52-		{FullID: "new-child", ShortID: "newc", Title: "New child", CreatedAt: now.Add(-1 * time.Hour), ParentID: "new-parent"},
 53+		{FullID: "old-child", ShortID: "oldc", Title: "Old child", CreatedAt: now.Add(-2 * time.Hour), ParentID: "old-parent"},
 54+		{FullID: "new-child", ShortID: "newc", Title: "New child", CreatedAt: now.Add(-1 * time.Hour), ParentID: "old-parent"},
 55 	}
 56 
 57 	roots := buildIssueTree(issues)
 58@@ -68,14 +68,17 @@ func TestBuildIssueTree_SortsRootsAndChildrenNewestFirst(t *testing.T) {
 59 	if len(roots) != 2 {
 60 		t.Fatalf("roots = %d, want 2", len(roots))
 61 	}
 62-	if roots[0].Issue.FullID != "new-parent" {
 63-		t.Errorf("first root = %q, want %q (newest parent first)", roots[0].Issue.FullID, "new-parent")
 64+	if roots[0].Issue.FullID != "old-parent" {
 65+		t.Errorf("first root = %q, want %q (oldest parent first)", roots[0].Issue.FullID, "old-parent")
 66 	}
 67 	if len(roots[0].Children) != 2 {
 68 		t.Fatalf("children = %d, want 2", len(roots[0].Children))
 69 	}
 70-	if roots[0].Children[0].Issue.FullID != "new-child" {
 71-		t.Errorf("first child = %q, want %q (newest child first)", roots[0].Children[0].Issue.FullID, "new-child")
 72+	if roots[0].Children[0].Issue.FullID != "old-child" {
 73+		t.Errorf("first child = %q, want %q (oldest child first)", roots[0].Children[0].Issue.FullID, "old-child")
 74+	}
 75+	if roots[0].Children[1].Issue.FullID != "new-child" {
 76+		t.Errorf("second child = %q, want %q (youngest child last)", roots[0].Children[1].Issue.FullID, "new-child")
 77 	}
 78 }
 79 
 80@@ -92,6 +95,46 @@ func TestBuildIssueTree_OrphanParentTreatedAsRoot(t *testing.T) {
 81 	}
 82 }
 83 
 84+func TestPrintTreeTable_ColumnsAligned(t *testing.T) {
 85+	now := time.Now()
 86+	issues := []BugIssue{
 87+		{FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
 88+		{FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
 89+		{FullID: "child2-full-id", ShortID: "child2", Title: "Child 2 with a longer title", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
 90+	}
 91+
 92+	roots := buildIssueTree(issues)
 93+	out := captureTreeOutput(t, roots)
 94+
 95+	lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
 96+	if len(lines) < 4 {
 97+		t.Fatalf("expected header, separator, and 3 rows, got:\n%s", out)
 98+	}
 99+
100+	// Every row's Summary, Labels, and Age columns must start at the
101+	// same rune index as the header's, even with multibyte tree markers.
102+	header := []rune(stripANSI(lines[0]))
103+	summaryAt := strings.Index(string(header), "Summary")
104+	labelsAt := strings.Index(string(header), "Labels")
105+	ageAt := strings.Index(string(header), "Age")
106+	if summaryAt < 0 || labelsAt < 0 || ageAt < 0 {
107+		t.Fatalf("header missing columns:\n%s", out)
108+	}
109+
110+	// The Summary text of each row must start exactly at summaryAt.
111+	wantSummaries := []string{"Parent", "Child 1", "Child 2 with a longer title"}
112+	for i, line := range lines[2:] {
113+		row := stripANSI(line)
114+		if len([]rune(row)) < summaryAt+len(wantSummaries[i]) {
115+			t.Errorf("row %d too short:\n%s", i, line)
116+			continue
117+		}
118+		if got := string([]rune(row)[summaryAt : summaryAt+len(wantSummaries[i])]); got != wantSummaries[i] {
119+			t.Errorf("row %d summary at column %d = %q, want %q\nfull output:\n%s", i, summaryAt, got, wantSummaries[i], out)
120+		}
121+	}
122+}
123+
124 func captureTreeOutput(t *testing.T, roots []*TreeNode) string {
125 	t.Helper()
126