5 files changed,
+142,
-57
+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.
+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)
+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