diff --git a/CHANGELOG.md b/CHANGELOG.md index 4760d92b..0ab192ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to td are documented in this file. ## [Unreleased] +### CLI + +- **`td history ` (alias `td timeline`) shows an issue's whole life in order:** creation, status changes, field updates (`priority: P2 → P1`), review records, logs, handoffs, comments, git snapshots, and linked files. The log a lifecycle command writes, such as an approval's reason, appears under its status change instead of as a duplicate line. Identical consecutive events, such as a retry loop posting the same comment, collapse into one with a count. `--limit N` keeps the most recent N events; `--json` returns the events with UTC timestamps. + +### Bug Fixes + +- **`td show` no longer drops handoffs or logs on older issues.** Handoffs written between December 2025 and February 2026 stored their lists base64-encoded, and failed to decode, so `td show` silently omitted them (938 rows in td's own project). They now decode in every handoff reader. A log row left with a NULL ID by the January 2026 text-ID migration made the whole issue's log query fail; it now reads. + ## [v0.67.0] - 2026-10-15 ### Sync Server diff --git a/README.md b/README.md index 469f0036..853eca58 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with ` | Log a decision | `td log --decision "chose X because Y"` | | Log a blocker | `td log --blocker "stuck on X"` | | View issue details | `td show ` | +| View an issue's timeline | `td history ` | | Capture handoff state | `td handoff --done "..." --remaining "..."` | | Submit for review | `td review ` | | See reviewable issues | `td reviewable` | diff --git a/cmd/history.go b/cmd/history.go new file mode 100644 index 00000000..88142639 --- /dev/null +++ b/cmd/history.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/marcus/td/internal/db" + "github.com/marcus/td/internal/models" + "github.com/marcus/td/internal/output" + "github.com/spf13/cobra" +) + +var historyCmd = &cobra.Command{ + Use: "history ", + Aliases: []string{"timeline"}, + Short: "Show chronological timeline of an issue's lifecycle", + Long: `Display a unified chronological timeline of all events for an issue. + +Merges status changes, field updates, reviews, logs, handoffs, comments, +git snapshots, and linked files into a single view. The log a lifecycle +command writes (e.g. an approval reason) is shown under its status change. + +Identical consecutive events (e.g. a retry loop posting the same comment) +collapse into one row with a count. Times are local in text output and UTC +in --json output. + +Examples: + td history td-abc1 # Full timeline + td history td-abc1 --limit 10 # Most recent 10 rows + td history td-abc1 --json # Machine-readable output`, + GroupID: "query", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // Args are validated by cobra; every later failure carries its own + // message, and for a --json caller usage text would corrupt the output. + cmd.SilenceUsage = true + isJSON := jsonMode(cmd) + + issueID := args[0] + if err := ValidateIssueID(issueID, "history "); err != nil { + return reportFailure(isJSON, output.ErrCodeInvalidInput, err) + } + + database, err := db.Open(getBaseDir()) + if err != nil { + return reportFailure(isJSON, output.ErrCodeDatabaseError, err) + } + defer func() { _ = database.Close() }() + + issue, err := database.GetIssue(issueID) + if err != nil { + return reportFailure(isJSON, output.ErrCodeNotFound, err) + } + + limit, _ := cmd.Flags().GetInt("limit") + if limit < 0 { + return reportFailure(isJSON, output.ErrCodeInvalidInput, fmt.Errorf("--limit must be 0 (no limit) or positive, got %d", limit)) + } + events, err := database.GetIssueTimeline(issue.ID, limit) + if err != nil { + return reportFailure(isJSON, output.ErrCodeDatabaseError, fmt.Errorf("build timeline: %w", err)) + } + if events == nil { + events = []models.TimelineEvent{} + } + + if isJSON { + result := map[string]any{ + "issue_id": issue.ID, + "title": issue.Title, + "status": issue.Status, + "events": events, + } + if issue.DeletedAt != nil { + result["deleted_at"] = issue.DeletedAt.UTC() + } + return output.JSON(result) + } + + state := string(issue.Status) + if issue.DeletedAt != nil { + state += ", deleted" + } + fmt.Printf("Timeline: %s %q [%s]\n\n", issue.ID, issue.Title, state) + if len(events) == 0 { + fmt.Println(" No events recorded.") + return nil + } + printTimeline(events) + return nil + }, +} + +// historyIndent aligns continuation lines under an event's summary: +// two spaces, a 16-char time, two spaces, a padded label, a space. +var historyIndent = strings.Repeat(" ", 2+16+2+historyLabelWidth+1) + +const historyLabelWidth = 7 + +var historyLabels = map[models.EventType]string{ + models.EventCreated: "created", + models.EventStatusChange: "status", + models.EventUpdate: "update", + models.EventReview: "review", + models.EventLog: "log", + models.EventHandoff: "handoff", + models.EventGitSnapshot: "git", + models.EventComment: "comment", + models.EventFileLink: "file", +} + +func printTimeline(events []models.TimelineEvent) { + lastSession := "" + for _, ev := range events { + label := historyLabels[ev.EventType] + if label == "" { + label = string(ev.EventType) + } + summary := indentContinuation(ev.Summary) + if ev.Count > 1 { + summary += fmt.Sprintf(" (×%d)", ev.Count) + } + // Name the session only when it changes, so a run of work by one + // session reads as a block. + if ev.SessionID != "" && ev.SessionID != lastSession { + summary += fmt.Sprintf(" (%s)", ev.SessionID) + lastSession = ev.SessionID + } + fmt.Printf(" %s %-*s %s\n", ev.Timestamp.Local().Format("2006-01-02 15:04"), historyLabelWidth, label, summary) + if ev.Detail != "" { + fmt.Printf("%s%s\n", historyIndent, indentContinuation(ev.Detail)) + } + } +} + +// indentContinuation indents every line after the first, leaving blank +// lines empty. +func indentContinuation(s string) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) != "" { + lines[i] = historyIndent + lines[i] + } else { + lines[i] = "" + } + } + return strings.Join(lines, "\n") +} + +func init() { + rootCmd.AddCommand(historyCmd) + + historyCmd.Flags().Bool("json", false, "Machine-readable JSON output") + historyCmd.Flags().Int("limit", 0, "Show only the most recent N rows (0 = all)") +} diff --git a/cmd/history_test.go b/cmd/history_test.go new file mode 100644 index 00000000..a2930053 --- /dev/null +++ b/cmd/history_test.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/marcus/td/internal/models" +) + +func TestPrintTimeline(t *testing.T) { + at := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC) + events := []models.TimelineEvent{ + {Timestamp: at, EventType: models.EventStatusChange, Summary: "Started work", SessionID: "ses_a"}, + {Timestamp: at, EventType: models.EventHandoff, Summary: "Handoff", Detail: "Done: x\n\nRemaining: y", SessionID: "ses_a"}, + {Timestamp: at, EventType: models.EventComment, Summary: "Comment: retry", SessionID: "ses_b", Count: 3}, + } + stamp := at.Local().Format("2006-01-02 15:04") + + got := captureStdout(t, func() { printTimeline(events) }) + want := " " + stamp + " status Started work (ses_a)\n" + + " " + stamp + " handoff Handoff\n" + + historyIndent + "Done: x\n" + + "\n" + + historyIndent + "Remaining: y\n" + + " " + stamp + " comment Comment: retry (×3) (ses_b)\n" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} + +func setHistoryFlags(t *testing.T, jsonOut bool, limit string) { + t.Helper() + for name, val := range map[string]string{"json": fmt.Sprint(jsonOut), "limit": limit} { + orig := historyCmd.Flags().Lookup(name).Value.String() + if err := historyCmd.Flags().Set(name, val); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + t.Cleanup(func() { _ = historyCmd.Flags().Set(name, orig) }) + } +} + +func TestHistoryJSON(t *testing.T) { + database, _ := setupReadJSONTest(t) + issue := newReadJSONIssue(t, database, "History JSON", models.StatusOpen, "") + if err := database.DeleteIssue(issue.ID); err != nil { + t.Fatalf("DeleteIssue: %v", err) + } + setHistoryFlags(t, true, "0") + + var runErr error + out := captureStdout(t, func() { runErr = historyCmd.RunE(historyCmd, []string{issue.ID}) }) + if runErr != nil { + t.Fatalf("history: %v", runErr) + } + var got struct { + IssueID string `json:"issue_id"` + DeletedAt *time.Time `json:"deleted_at"` + Events []models.TimelineEvent `json:"events"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out) + } + if got.IssueID != issue.ID || got.DeletedAt == nil || len(got.Events) == 0 || got.Events[0].EventType != models.EventCreated { + t.Fatalf("unexpected payload: %s", out) + } +} + +func TestHistoryJSONErrors(t *testing.T) { + database, _ := setupReadJSONTest(t) + issue := newReadJSONIssue(t, database, "History errors", models.StatusOpen, "") + + for _, tc := range []struct { + name, id, limit, code string + }{ + {"not found", "td-doesnotexist", "0", "not_found"}, + {"negative limit", issue.ID, "-1", "invalid_input"}, + } { + t.Run(tc.name, func(t *testing.T) { + setHistoryFlags(t, true, tc.limit) + var runErr error + out := captureStdout(t, func() { runErr = historyCmd.RunE(historyCmd, []string{tc.id}) }) + requireSilentFailure(t, runErr) + var env struct { + Error struct{ Code string } `json:"error"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &env); err != nil || env.Error.Code != tc.code { + t.Fatalf("want %s envelope, got %q (%v)", tc.code, out, err) + } + }) + } +} diff --git a/internal/db/activity.go b/internal/db/activity.go index 78b06427..1c17c093 100644 --- a/internal/db/activity.go +++ b/internal/db/activity.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "encoding/base64" "encoding/json" "fmt" "time" @@ -57,7 +58,9 @@ func (db *DB) GetLogs(issueID string, limit int) ([]models.Log, error) { // Get logs that are either: // 1. Directly assigned to this issue (issue_id = ?) // 2. Work session logs (issue_id = '') from sessions where this issue is tagged - query := `SELECT CAST(l.id AS TEXT), l.issue_id, l.session_id, l.work_session_id, l.message, l.type, l.timestamp + // COALESCE: a log row left with a NULL id by the Jan 2026 text-ID + // migration would otherwise fail the scan and lose every log for the issue. + query := `SELECT COALESCE(CAST(l.id AS TEXT), ''), l.issue_id, l.session_id, l.work_session_id, l.message, l.type, l.timestamp FROM logs l WHERE l.issue_id = ? OR (l.issue_id = '' AND l.work_session_id IN ( @@ -265,6 +268,22 @@ func (db *DB) AddHandoff(handoff *models.Handoff) error { }) } +// decodeHandoffList decodes a handoff list column. Handoffs written between +// roughly Dec 2025 and Feb 2026 stored the JSON base64-encoded (a []byte +// round-trip), e.g. "W10=" for "[]"; those rows are decoded transparently. +func decodeHandoffList(raw string, dst *[]string) error { + err := json.Unmarshal([]byte(raw), dst) + if err == nil { + return nil + } + if decoded, b64err := base64.StdEncoding.DecodeString(raw); b64err == nil { + if json.Unmarshal(decoded, dst) == nil { + return nil + } + } + return err +} + // GetLatestHandoff retrieves the latest handoff for an issue func (db *DB) GetLatestHandoff(issueID string) (*models.Handoff, error) { var handoff models.Handoff @@ -285,16 +304,16 @@ func (db *DB) GetLatestHandoff(issueID string) (*models.Handoff, error) { return nil, err } - if err := json.Unmarshal([]byte(doneJSON), &handoff.Done); err != nil { + if err := decodeHandoffList(doneJSON, &handoff.Done); err != nil { return nil, fmt.Errorf("failed to unmarshal done: %w", err) } - if err := json.Unmarshal([]byte(remainingJSON), &handoff.Remaining); err != nil { + if err := decodeHandoffList(remainingJSON, &handoff.Remaining); err != nil { return nil, fmt.Errorf("failed to unmarshal remaining: %w", err) } - if err := json.Unmarshal([]byte(decisionsJSON), &handoff.Decisions); err != nil { + if err := decodeHandoffList(decisionsJSON, &handoff.Decisions); err != nil { return nil, fmt.Errorf("failed to unmarshal decisions: %w", err) } - if err := json.Unmarshal([]byte(uncertainJSON), &handoff.Uncertain); err != nil { + if err := decodeHandoffList(uncertainJSON, &handoff.Uncertain); err != nil { return nil, fmt.Errorf("failed to unmarshal uncertain: %w", err) } @@ -322,16 +341,16 @@ func (db *DB) GetHandoffs(issueID string) ([]models.Handoff, error) { if err != nil { return nil, fmt.Errorf("failed to scan handoff row: %w", err) } - if err := json.Unmarshal([]byte(doneJSON), &h.Done); err != nil { + if err := decodeHandoffList(doneJSON, &h.Done); err != nil { return nil, fmt.Errorf("failed to unmarshal done: %w", err) } - if err := json.Unmarshal([]byte(remainingJSON), &h.Remaining); err != nil { + if err := decodeHandoffList(remainingJSON, &h.Remaining); err != nil { return nil, fmt.Errorf("failed to unmarshal remaining: %w", err) } - if err := json.Unmarshal([]byte(decisionsJSON), &h.Decisions); err != nil { + if err := decodeHandoffList(decisionsJSON, &h.Decisions); err != nil { return nil, fmt.Errorf("failed to unmarshal decisions: %w", err) } - if err := json.Unmarshal([]byte(uncertainJSON), &h.Uncertain); err != nil { + if err := decodeHandoffList(uncertainJSON, &h.Uncertain); err != nil { return nil, fmt.Errorf("failed to unmarshal uncertain: %w", err) } handoffs = append(handoffs, h) @@ -369,16 +388,16 @@ func (db *DB) GetRecentHandoffs(limit int, since time.Time) ([]models.Handoff, e if err != nil { return nil, fmt.Errorf("failed to scan handoff row: %w", err) } - if err := json.Unmarshal([]byte(doneJSON), &h.Done); err != nil { + if err := decodeHandoffList(doneJSON, &h.Done); err != nil { return nil, fmt.Errorf("failed to unmarshal done: %w", err) } - if err := json.Unmarshal([]byte(remainingJSON), &h.Remaining); err != nil { + if err := decodeHandoffList(remainingJSON, &h.Remaining); err != nil { return nil, fmt.Errorf("failed to unmarshal remaining: %w", err) } - if err := json.Unmarshal([]byte(decisionsJSON), &h.Decisions); err != nil { + if err := decodeHandoffList(decisionsJSON, &h.Decisions); err != nil { return nil, fmt.Errorf("failed to unmarshal decisions: %w", err) } - if err := json.Unmarshal([]byte(uncertainJSON), &h.Uncertain); err != nil { + if err := decodeHandoffList(uncertainJSON, &h.Uncertain); err != nil { return nil, fmt.Errorf("failed to unmarshal uncertain: %w", err) } handoffs = append(handoffs, h) diff --git a/internal/db/history.go b/internal/db/history.go new file mode 100644 index 00000000..8f003e1f --- /dev/null +++ b/internal/db/history.go @@ -0,0 +1,482 @@ +package db + +import ( + "encoding/json" + "fmt" + "reflect" + "slices" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/marcus/td/internal/models" +) + +// Lifecycle commands write an action_log row and then a log entry narrating +// it, usually within a quarter second (start takes a git snapshot in +// between, so up to a few seconds). That log is folded into the action's +// event as its detail instead of appearing as a separate event. The small +// negative slack only absorbs clock rounding between the two writes. +const ( + timelinePairBefore = 500 * time.Millisecond + timelinePairAfter = 5 * time.Second +) + +// narratingLogTypes lists the actions whose CLI command writes its own log, +// and the log types it writes. Only these pair: update, delete, and restore +// write no log, so a note logged next to them is the user's own. Some paths +// (cascades, programmatic closes) record these actions without a log; a +// progress note logged within the window of one of those would be folded +// in as its detail, which still shows it. +var narratingLogTypes = map[models.ActionType][]models.LogType{ + models.ActionStart: {models.LogTypeProgress}, + models.ActionReview: {models.LogTypeProgress}, + models.ActionApprove: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionReject: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionClose: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionCloseAfterReview: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionReviewApprove: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionReviewChangesRequested: {models.LogTypeProgress, models.LogTypeSecurity}, + models.ActionBlock: {models.LogTypeBlocker, models.LogTypeProgress}, + models.ActionUnblock: {models.LogTypeProgress}, + models.ActionReopen: {models.LogTypeProgress}, +} + +// timelineAction is an action event plus the log types its command narrates +// itself with (none for actions that write no log). +type timelineAction struct { + event models.TimelineEvent + logTypes []models.LogType +} + +// commentSummaryLen is the longest comment, in runes, shown inline in the +// summary rather than as detail. +const commentSummaryLen = 80 + +// GetIssueTimeline returns a chronologically sorted timeline of an issue: +// lifecycle actions, field updates, logs, handoffs, comments, git snapshots, +// and linked files. If limit > 0, only the most recent limit events are +// returned. Event timestamps are UTC. +func (db *DB) GetIssueTimeline(issueID string, limit int) ([]models.TimelineEvent, error) { + issue, err := db.GetIssue(issueID) + if err != nil { + return nil, err + } + issueID = issue.ID + + actions, err := db.getIssueActions(issueID) + if err != nil { + return nil, fmt.Errorf("query action log: %w", err) + } + var timelineActions []timelineAction + hasCreate := false + for _, a := range actions { + ev, ok := issueActionToEvent(a) + if !ok { + continue + } + if ev.EventType == models.EventCreated { + hasCreate = true + } + timelineActions = append(timelineActions, timelineAction{ev, narratingLogTypes[a.ActionType]}) + } + // Issues created without an action_log row (imports, older data) still + // get a starting point. + if !hasCreate && !issue.CreatedAt.IsZero() { + timelineActions = append([]timelineAction{{event: models.TimelineEvent{ + Timestamp: issue.CreatedAt, + EventType: models.EventCreated, + Summary: createdSummary(string(issue.Type), string(issue.Priority)), + SessionID: issue.CreatorSession, + }}}, timelineActions...) + } + + logs, err := db.GetLogs(issueID, 0) + if err != nil { + return nil, fmt.Errorf("query logs: %w", err) + } + events := foldLogsIntoActions(timelineActions, logs) + + handoffs, err := db.GetHandoffs(issueID) + if err != nil { + return nil, fmt.Errorf("query handoffs: %w", err) + } + for _, h := range handoffs { + events = append(events, models.TimelineEvent{ + Timestamp: h.Timestamp, + EventType: models.EventHandoff, + Summary: "Handoff", + Detail: formatHandoffDetail(h), + SessionID: h.SessionID, + }) + } + + comments, err := db.GetComments(issueID) + if err != nil { + return nil, fmt.Errorf("query comments: %w", err) + } + for _, c := range comments { + // A short one-line comment fits in the summary; anything longer is + // shown once, in full, as the detail. + ev := models.TimelineEvent{ + Timestamp: c.CreatedAt, + EventType: models.EventComment, + Summary: "Comment", + SessionID: c.SessionID, + } + text := strings.TrimSpace(c.Text) + if utf8.RuneCountInString(text) <= commentSummaryLen && !strings.Contains(text, "\n") { + ev.Summary += ": " + text + } else { + ev.Detail = text + } + events = append(events, ev) + } + + snapshots, err := db.getGitSnapshots(issueID) + if err != nil { + return nil, fmt.Errorf("query git snapshots: %w", err) + } + for _, s := range snapshots { + summary := fmt.Sprintf("Git %s: %s on %s", s.Event, shortSHA(s.CommitSHA), s.Branch) + if s.DirtyFiles > 0 { + summary += fmt.Sprintf(" (%d dirty)", s.DirtyFiles) + } + events = append(events, models.TimelineEvent{ + Timestamp: s.Timestamp, + EventType: models.EventGitSnapshot, + Summary: summary, + }) + } + + files, err := db.GetLinkedFiles(issueID) + if err != nil { + return nil, fmt.Errorf("query linked files: %w", err) + } + for _, f := range files { + events = append(events, models.TimelineEvent{ + Timestamp: f.LinkedAt, + EventType: models.EventFileLink, + Summary: fmt.Sprintf("Linked file: %s (%s)", f.FilePath, f.Role), + }) + } + + for i := range events { + events[i].Timestamp = events[i].Timestamp.UTC() + } + sort.SliceStable(events, func(i, j int) bool { + return events[i].Timestamp.Before(events[j].Timestamp) + }) + + events = collapseRepeats(events) + + if limit > 0 && len(events) > limit { + events = events[len(events)-limit:] + } + return events, nil +} + +// collapseRepeats merges runs of identical consecutive events into one +// event carrying a Count. +func collapseRepeats(events []models.TimelineEvent) []models.TimelineEvent { + var out []models.TimelineEvent + for _, ev := range events { + if n := len(out); n > 0 { + last := &out[n-1] + if last.EventType == ev.EventType && last.Summary == ev.Summary && + last.Detail == ev.Detail && last.SessionID == ev.SessionID { + if last.Count == 0 { + last.Count = 1 + } + last.Count++ + continue + } + } + out = append(out, ev) + } + return out +} + +// foldLogsIntoActions attaches each narrating log to the action it narrates +// and returns all action events followed by the remaining logs as events of +// their own. A log pairs with an action from the same session, of a type that +// action's command writes, inside the pairing window; closest pairs are +// matched first, and each side pairs at most once. A paired log that only +// repeats the summary ("Approved" for "Approved and closed") is dropped, and +// a leading ": " is trimmed ("Rejected: reason" becomes "reason"). +func foldLogsIntoActions(actions []timelineAction, logs []models.Log) []models.TimelineEvent { + type candidate struct { + action, log int + gap time.Duration + } + var candidates []candidate + for ai, a := range actions { + if len(a.logTypes) == 0 || a.event.SessionID == "" { + continue + } + for li, l := range logs { + if l.SessionID != a.event.SessionID || !slices.Contains(a.logTypes, l.Type) { + continue + } + gap := l.Timestamp.Sub(a.event.Timestamp) + if gap < -timelinePairBefore || gap > timelinePairAfter { + continue + } + candidates = append(candidates, candidate{ai, li, gap.Abs()}) + } + } + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].gap < candidates[j].gap }) + + events := make([]models.TimelineEvent, 0, len(actions)+len(logs)) + for _, a := range actions { + events = append(events, a.event) + } + actionPaired := make([]bool, len(actions)) + logPaired := make([]bool, len(logs)) + for _, c := range candidates { + if actionPaired[c.action] || logPaired[c.log] { + continue + } + actionPaired[c.action], logPaired[c.log] = true, true + events[c.action].Detail = narrationDetail(events[c.action].Summary, logs[c.log].Message) + } + + for li, l := range logs { + if logPaired[li] { + continue + } + summary := l.Message + if l.Type != "" && l.Type != models.LogTypeProgress { + summary = fmt.Sprintf("[%s] %s", l.Type, l.Message) + } + events = append(events, models.TimelineEvent{ + Timestamp: l.Timestamp, + EventType: models.EventLog, + Summary: summary, + SessionID: l.SessionID, + }) + } + return events +} + +// narrationDetail returns what a narrating log adds to its action's summary. +func narrationDetail(summary, msg string) string { + msg = strings.TrimSpace(msg) + lowerSummary, lowerMsg := strings.ToLower(summary), strings.ToLower(msg) + if strings.HasPrefix(lowerSummary, lowerMsg) { + return "" + } + // Summaries are ASCII, so their byte length is the same before and + // after lower-casing and slicing msg by it is safe. + if strings.HasPrefix(lowerMsg, lowerSummary+":") { + return strings.TrimSpace(msg[len(summary)+1:]) + } + return msg +} + +// getGitSnapshots returns all git snapshots for an issue. +func (db *DB) getGitSnapshots(issueID string) ([]models.GitSnapshot, error) { + rows, err := db.conn.Query(` + SELECT CAST(id AS TEXT), issue_id, event, commit_sha, branch, dirty_files, timestamp + FROM git_snapshots WHERE issue_id = ? ORDER BY timestamp + `, issueID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var snapshots []models.GitSnapshot + for rows.Next() { + var s models.GitSnapshot + if err := rows.Scan(&s.ID, &s.IssueID, &s.Event, &s.CommitSHA, &s.Branch, &s.DirtyFiles, &s.Timestamp); err != nil { + return nil, err + } + snapshots = append(snapshots, s) + } + return snapshots, rows.Err() +} + +// getIssueActions returns the issue's own action_log rows, oldest first, +// excluding undone actions. +func (db *DB) getIssueActions(issueID string) ([]models.ActionLog, error) { + rows, err := db.conn.Query(` + SELECT CAST(id AS TEXT), session_id, action_type, entity_type, entity_id, + previous_data, new_data, timestamp + FROM action_log + WHERE entity_type = 'issue' AND entity_id = ? AND undone = 0 + ORDER BY timestamp + `, issueID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var actions []models.ActionLog + for rows.Next() { + var a models.ActionLog + if err := rows.Scan(&a.ID, &a.SessionID, &a.ActionType, &a.EntityType, + &a.EntityID, &a.PreviousData, &a.NewData, &a.Timestamp); err != nil { + return nil, err + } + actions = append(actions, a) + } + return actions, rows.Err() +} + +// actionSummaries maps lifecycle actions to their timeline event. +var actionSummaries = map[models.ActionType]struct { + eventType models.EventType + summary string +}{ + models.ActionStart: {models.EventStatusChange, "Started work"}, + models.ActionReview: {models.EventStatusChange, "Submitted for review"}, + models.ActionApprove: {models.EventStatusChange, "Approved and closed"}, + models.ActionCloseAfterReview: {models.EventStatusChange, "Closed after review"}, + models.ActionReject: {models.EventStatusChange, "Rejected"}, + models.ActionBlock: {models.EventStatusChange, "Blocked"}, + models.ActionUnblock: {models.EventStatusChange, "Unblocked"}, + models.ActionClose: {models.EventStatusChange, "Closed"}, + models.ActionReopen: {models.EventStatusChange, "Reopened"}, + models.ActionDelete: {models.EventStatusChange, "Deleted"}, + models.ActionRestore: {models.EventStatusChange, "Restored"}, + models.ActionReviewApprove: {models.EventReview, "Review recorded: approved"}, + models.ActionReviewChangesRequested: {models.EventReview, "Review recorded: changes requested"}, +} + +func issueActionToEvent(a models.ActionLog) (models.TimelineEvent, bool) { + ev := models.TimelineEvent{Timestamp: a.Timestamp, SessionID: a.SessionID} + switch a.ActionType { + case models.ActionUpdate: + return issueUpdateToEvent(a, ev) + case models.ActionCreate: + var created struct{ Type, Priority string } + _ = json.Unmarshal([]byte(a.NewData), &created) // best effort: the summary just omits what it can't read + ev.EventType, ev.Summary = models.EventCreated, createdSummary(created.Type, created.Priority) + return ev, true + } + s, ok := actionSummaries[a.ActionType] + if !ok { + return ev, false + } + ev.EventType, ev.Summary = s.eventType, s.summary + return ev, true +} + +// createdSummary reads "Created task (P1)", dropping whatever is unknown. +func createdSummary(issueType, priority string) string { + s := "Created" + if issueType != "" { + s += " " + issueType + } + if priority != "" { + s += " (" + priority + ")" + } + return s +} + +// timelineIgnoredFields change on every write and say nothing about the issue. +var timelineIgnoredFields = map[string]bool{"updated_at": true} + +// issueUpdateToEvent describes an update by the fields it changed. An update +// that changed only bookkeeping fields produces no event. +func issueUpdateToEvent(a models.ActionLog, ev models.TimelineEvent) (models.TimelineEvent, bool) { + var prev, next map[string]any + if json.Unmarshal([]byte(a.PreviousData), &prev) != nil || json.Unmarshal([]byte(a.NewData), &next) != nil || + prev == nil || next == nil { + ev.EventType, ev.Summary = models.EventUpdate, "Updated" + return ev, true + } + + // Union of both key sets: omitempty fields vanish from new_data when + // they are cleared. + var keys []string + seen := map[string]bool{} + for _, m := range []map[string]any{prev, next} { + for k := range m { + if seen[k] || timelineIgnoredFields[k] { + continue + } + seen[k] = true + if !reflect.DeepEqual(prev[k], next[k]) { + keys = append(keys, k) + } + } + } + if len(keys) == 0 { + return ev, false + } + sort.Strings(keys) + + var changes []string + statusChanged := false + for _, k := range keys { + if k == "status" { + statusChanged = true + } + changes = append(changes, describeFieldChange(k, prev[k], next[k])) + } + ev.EventType = models.EventUpdate + if statusChanged { + ev.EventType = models.EventStatusChange + } + ev.Summary = "Updated " + strings.Join(changes, ", ") + return ev, true +} + +// describeFieldChange shows short scalar changes as "field: old → new" and +// anything longer by name only. +func describeFieldChange(field string, oldVal, newVal any) string { + oldS, okOld := shortScalar(oldVal) + newS, okNew := shortScalar(newVal) + if !okOld || !okNew { + return field + } + return fmt.Sprintf("%s: %s → %s", field, oldS, newS) +} + +func shortScalar(v any) (string, bool) { + var s string + switch x := v.(type) { + case nil: + s = "(none)" + case string: + s = x + if s == "" { + s = "(none)" + } + case bool, float64: + s = fmt.Sprint(x) + default: + return "", false + } + if utf8.RuneCountInString(s) > 40 || strings.Contains(s, "\n") { + return "", false + } + return s, true +} + +func formatHandoffDetail(h models.Handoff) string { + var parts []string + for _, section := range []struct { + label string + items []string + }{ + {"Done", h.Done}, + {"Remaining", h.Remaining}, + {"Decisions", h.Decisions}, + {"Uncertain", h.Uncertain}, + } { + if len(section.items) > 0 { + parts = append(parts, section.label+": "+strings.Join(section.items, "; ")) + } + } + return strings.Join(parts, "\n") +} + +func shortSHA(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} diff --git a/internal/db/history_test.go b/internal/db/history_test.go new file mode 100644 index 00000000..eceb1c3a --- /dev/null +++ b/internal/db/history_test.go @@ -0,0 +1,365 @@ +package db + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/marcus/td/internal/models" +) + +func newTimelineDB(t *testing.T) (*DB, *models.Issue) { + t.Helper() + db, err := Initialize(t.TempDir()) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + issue := &models.Issue{Title: "Timeline test", Type: models.TypeTask, Priority: models.PriorityP2} + if err := db.CreateIssue(issue); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + return db, issue +} + +var timelineSeq int + +// addActionAt writes an issue action_log row at an exact time. +func addActionAt(t *testing.T, db *DB, issueID, session string, action models.ActionType, prev, next string, at time.Time) { + t.Helper() + timelineSeq++ + if _, err := db.conn.Exec(` + INSERT INTO action_log (id, session_id, action_type, entity_type, entity_id, previous_data, new_data, timestamp, undone) + VALUES (?, ?, ?, 'issue', ?, ?, ?, ?, 0)`, + fmt.Sprintf("al-test%04d", timelineSeq), session, action, issueID, prev, next, formatActionLogTimestamp(at.UTC()), + ); err != nil { + t.Fatalf("insert action: %v", err) + } +} + +// addLogAt writes a log at an exact time, in local time as AddLog does. +func addLogAt(t *testing.T, db *DB, issueID, session, msg string, typ models.LogType, at time.Time) { + t.Helper() + timelineSeq++ + if _, err := db.conn.Exec(` + INSERT INTO logs (id, issue_id, session_id, work_session_id, message, type, timestamp) + VALUES (?, ?, ?, '', ?, ?, ?)`, + fmt.Sprintf("lg-test%04d", timelineSeq), issueID, session, msg, typ, at.Local(), + ); err != nil { + t.Fatalf("insert log: %v", err) + } +} + +func timeline(t *testing.T, db *DB, issueID string, limit int) []models.TimelineEvent { + t.Helper() + events, err := db.GetIssueTimeline(issueID, limit) + if err != nil { + t.Fatalf("GetIssueTimeline failed: %v", err) + } + return events +} + +func summaries(events []models.TimelineEvent) string { + var b strings.Builder + for _, ev := range events { + fmt.Fprintf(&b, "%s: %s", ev.EventType, ev.Summary) + if ev.Detail != "" { + fmt.Fprintf(&b, " / %s", ev.Detail) + } + if ev.Count > 0 { + fmt.Fprintf(&b, " x%d", ev.Count) + } + b.WriteString("\n") + } + return b.String() +} + +func TestGetIssueTimeline_UnknownIssue(t *testing.T) { + db, _ := newTimelineDB(t) + if _, err := db.GetIssueTimeline("td-nope00", 0); err == nil { + t.Fatal("expected an error for an unknown issue") + } +} + +func TestGetIssueTimeline_SynthesizesCreated(t *testing.T) { + // CreateIssue writes no action_log row; the timeline still starts with + // the issue's creation. + db, issue := newTimelineDB(t) + events := timeline(t, db, issue.ID, 0) + if len(events) != 1 || events[0].EventType != models.EventCreated || events[0].Summary != "Created task (P2)" { + t.Fatalf("got:\n%s", summaries(events)) + } +} + +func TestGetIssueTimeline_ChronologicalAndUTC(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + // Logs are stored in local time and actions in UTC; the timeline must + // order them by instant and report all of them in UTC. + addLogAt(t, db, issue.ID, "ses_a", "second", models.LogTypeProgress, base.Add(2*time.Minute)) + addActionAt(t, db, issue.ID, "ses_b", models.ActionBlock, "", "", base.Add(time.Minute)) + addLogAt(t, db, issue.ID, "ses_a", "fourth", models.LogTypeProgress, base.Add(4*time.Minute)) + addActionAt(t, db, issue.ID, "ses_b", models.ActionUnblock, "", "", base.Add(3*time.Minute)) + + events := timeline(t, db, issue.ID, 0) + want := "created: Created task (P2)\nstatus_change: Blocked\nlog: second\nstatus_change: Unblocked\nlog: fourth\n" + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } + for _, ev := range events { + if ev.Timestamp.Location() != time.UTC { + t.Errorf("%s timestamp %v is not UTC", ev.Summary, ev.Timestamp) + } + } +} + +func TestGetIssueTimeline_FoldsLifecycleLogs(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + // start writes "Started work" a few seconds after its action: it only + // repeats the summary, so it disappears. + addActionAt(t, db, issue.ID, "ses_a", models.ActionStart, "", "", base) + addLogAt(t, db, issue.ID, "ses_a", "Started work", models.LogTypeProgress, base.Add(4*time.Second)) + // Another session's log at the same moment is not folded. + addLogAt(t, db, issue.ID, "ses_other", "unrelated note", models.LogTypeProgress, base.Add(5*time.Second)) + // The same session's log well outside the window is not folded either. + addLogAt(t, db, issue.ID, "ses_a", "later note", models.LogTypeProgress, base.Add(time.Minute)) + + // review then approve one second apart: each log goes to its own action. + r := base.Add(10 * time.Minute) + addActionAt(t, db, issue.ID, "ses_r", models.ActionReview, "", "", r) + addActionAt(t, db, issue.ID, "ses_r", models.ActionApprove, "", "", r.Add(time.Second)) + addLogAt(t, db, issue.ID, "ses_r", "Submitted for review", models.LogTypeProgress, r.Add(100*time.Millisecond)) + addLogAt(t, db, issue.ID, "ses_r", "Approved (self-review): tests pass", models.LogTypeSecurity, r.Add(1100*time.Millisecond)) + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "status_change: Started work", + "log: unrelated note", + "log: later note", + "status_change: Submitted for review", + "status_change: Approved and closed / Approved (self-review): tests pass", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_UpdateDoesNotSwallowNextLog(t *testing.T) { + // `td update ... && td log ...` runs within milliseconds; the note is the + // agent's own and must stay a log, not become the update's detail. + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `{"priority":"P2"}`, `{"priority":"P1"}`, base) + addLogAt(t, db, issue.ID, "ses_a", "bumped because of the outage", models.LogTypeProgress, base.Add(50*time.Millisecond)) + + events := timeline(t, db, issue.ID, 0) + want := "created: Created task (P2)\nupdate: Updated priority: P2 → P1\nlog: bumped because of the outage\n" + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_OnlyNarratingActionsTakeLogs(t *testing.T) { + // Agents chain commands within milliseconds. A typed note logged next to + // an action whose command writes no log of that type is the agent's own + // and must stay a standalone log with its type. + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + // td update --status blocked; td log --blocker ... + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `{"status":"open"}`, `{"status":"blocked"}`, base) + addLogAt(t, db, issue.ID, "ses_a", "waiting on API keys", models.LogTypeBlocker, base.Add(30*time.Millisecond)) + + // td log --decision ...; td delete (the log comes first). + d := base.Add(time.Minute) + addLogAt(t, db, issue.ID, "ses_a", "use approach B", models.LogTypeDecision, d) + addActionAt(t, db, issue.ID, "ses_a", models.ActionDelete, "", "", d.Add(20*time.Millisecond)) + + // td start; td log --decision ...: start narrates with progress only. + st := base.Add(2 * time.Minute) + addActionAt(t, db, issue.ID, "ses_a", models.ActionStart, "", "", st) + addLogAt(t, db, issue.ID, "ses_a", "going with B", models.LogTypeDecision, st.Add(10*time.Millisecond)) + + // A narrating log written over half a second before its action is not + // its narration. + rj := base.Add(3 * time.Minute) + addLogAt(t, db, issue.ID, "ses_a", "early note", models.LogTypeProgress, rj.Add(-2*time.Second)) + addActionAt(t, db, issue.ID, "ses_a", models.ActionReject, "", "", rj) + addLogAt(t, db, issue.ID, "ses_a", "Rejected: missing tests", models.LogTypeProgress, rj.Add(100*time.Millisecond)) + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "status_change: Updated status: open → blocked", + "log: [blocker] waiting on API keys", + "log: [decision] use approach B", + "status_change: Deleted", + "status_change: Started work", + "log: [decision] going with B", + "log: early note", + "status_change: Rejected / missing tests", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_LegacyBase64Handoff(t *testing.T) { + // Handoffs from Dec 2025-Feb 2026 stored their lists base64-encoded. + db, issue := newTimelineDB(t) + if _, err := db.conn.Exec(` + INSERT INTO handoffs (id, issue_id, session_id, done, remaining, decisions, uncertain, timestamp) + VALUES ('ho-legacy', ?, 'ses_a', 'WyJzaGlwcGVkIl0=', 'W10=', 'W10=', 'W10=', ?)`, + issue.ID, issue.CreatedAt.Add(time.Hour), + ); err != nil { + t.Fatalf("insert legacy handoff: %v", err) + } + + events := timeline(t, db, issue.ID, 0) + if got, want := summaries(events), "created: Created task (P2)\nhandoff: Handoff / Done: shipped\n"; got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_NullLogID(t *testing.T) { + // One real log row was left with a NULL id by the text-ID migration. + db, issue := newTimelineDB(t) + if _, err := db.conn.Exec(` + INSERT INTO logs (id, issue_id, session_id, work_session_id, message, type, timestamp) + VALUES (NULL, ?, 'ses_a', '', 'orphaned id', 'progress', ?)`, + issue.ID, issue.CreatedAt.Add(time.Hour), + ); err != nil { + t.Skipf("schema rejects NULL log ids: %v", err) + } + + events := timeline(t, db, issue.ID, 0) + if got, want := summaries(events), "created: Created task (P2)\nlog: orphaned id\n"; got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_Updates(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"priority":"P2","updated_at":"a"}`, `{"priority":"P2","updated_at":"b"}`, base) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"priority":"P2","description":"x","labels":["api"]}`, + `{"priority":"P1","description":"`+strings.Repeat("long ", 20)+`","labels":["api","ui"]}`, base.Add(time.Minute)) + // Clearing an omitempty field drops its key from new_data entirely. + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"priority":"P1","sprint":"s1"}`, `{"priority":"P1"}`, base.Add(90*time.Second)) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, + `{"status":"open"}`, `{"status":"blocked"}`, base.Add(2*time.Minute)) + addActionAt(t, db, issue.ID, "ses_a", models.ActionUpdate, `not json`, `{}`, base.Add(3*time.Minute)) + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "update: Updated description, labels, priority: P2 → P1", + "update: Updated sprint: s1 → (none)", + "status_change: Updated status: open → blocked", + "update: Updated", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_SkipsUndoneActions(t *testing.T) { + db, issue := newTimelineDB(t) + addActionAt(t, db, issue.ID, "ses_a", models.ActionClose, "", "", issue.CreatedAt.Add(time.Hour)) + if _, err := db.conn.Exec(`UPDATE action_log SET undone = 1 WHERE entity_id = ?`, issue.ID); err != nil { + t.Fatalf("mark undone: %v", err) + } + if events := timeline(t, db, issue.ID, 0); len(events) != 1 { + t.Fatalf("undone close still shown:\n%s", summaries(events)) + } +} + +func TestGetIssueTimeline_CommentsAndRepeats(t *testing.T) { + db, issue := newTimelineDB(t) + long := strings.Repeat("é", 100) // multi-byte: must not be cut mid-rune + for _, text := range []string{"looks good", long, "line one\nline two", "retry failed", "retry failed", "retry failed"} { + if err := db.AddComment(&models.Comment{IssueID: issue.ID, SessionID: "ses_c", Text: text}); err != nil { + t.Fatalf("AddComment: %v", err) + } + time.Sleep(2 * time.Millisecond) + } + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "comment: Comment: looks good", + "comment: Comment / " + long, + "comment: Comment / line one\nline two", + "comment: Comment: retry failed x3", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_HandoffsSnapshotsFiles(t *testing.T) { + db, issue := newTimelineDB(t) + if err := db.AddGitSnapshot(&models.GitSnapshot{ + IssueID: issue.ID, Event: "start", CommitSHA: "abc1234567890", Branch: "main", DirtyFiles: 2, + }); err != nil { + t.Fatalf("AddGitSnapshot: %v", err) + } + time.Sleep(2 * time.Millisecond) + if err := db.AddHandoff(&models.Handoff{ + IssueID: issue.ID, SessionID: "ses_a", Done: []string{"a", "b"}, Uncertain: []string{"c"}, + }); err != nil { + t.Fatalf("AddHandoff: %v", err) + } + time.Sleep(2 * time.Millisecond) + if err := db.LinkFile(issue.ID, "cmd/history.go", models.FileRoleImplementation, "deadbeef"); err != nil { + t.Fatalf("LinkFile: %v", err) + } + + events := timeline(t, db, issue.ID, 0) + want := strings.Join([]string{ + "created: Created task (P2)", + "git_snapshot: Git start: abc1234 on main (2 dirty)", + "handoff: Handoff / Done: a; b\nUncertain: c", + "file_link: Linked file: cmd/history.go (implementation)", + "", + }, "\n") + if got := summaries(events); got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestGetIssueTimeline_Limit(t *testing.T) { + db, issue := newTimelineDB(t) + base := issue.CreatedAt.Add(time.Hour) + for i := 0; i < 5; i++ { + addLogAt(t, db, issue.ID, "ses_a", fmt.Sprintf("log %d", i), models.LogTypeProgress, base.Add(time.Duration(i)*time.Minute)) + } + + events := timeline(t, db, issue.ID, 2) + if got, want := summaries(events), "log: log 3\nlog: log 4\n"; got != want { + t.Fatalf("limit keeps the most recent events; got:\n%s", got) + } + + // A collapsed run counts as one row. + for i := 0; i < 3; i++ { + addLogAt(t, db, issue.ID, "ses_a", "retry", models.LogTypeProgress, base.Add(time.Hour+time.Duration(i)*time.Second)) + } + events = timeline(t, db, issue.ID, 2) + if got, want := summaries(events), "log: log 4\nlog: retry x3\n"; got != want { + t.Fatalf("limit after collapsing; got:\n%s", got) + } +} diff --git a/internal/models/history.go b/internal/models/history.go new file mode 100644 index 00000000..4931d524 --- /dev/null +++ b/internal/models/history.go @@ -0,0 +1,31 @@ +package models + +import "time" + +// EventType categorizes timeline events +type EventType string + +const ( + EventCreated EventType = "created" + EventStatusChange EventType = "status_change" + EventUpdate EventType = "update" + EventReview EventType = "review" + EventLog EventType = "log" + EventHandoff EventType = "handoff" + EventGitSnapshot EventType = "git_snapshot" + EventComment EventType = "comment" + EventFileLink EventType = "file_link" +) + +// TimelineEvent represents a single event in an issue's history. +// Timestamps are always UTC. Count is set when a run of identical +// consecutive events (e.g. a retry loop posting the same comment) was +// collapsed into this one; Timestamp is then the first occurrence. +type TimelineEvent struct { + Timestamp time.Time `json:"timestamp"` + EventType EventType `json:"event_type"` + Summary string `json:"summary"` + Detail string `json:"detail,omitempty"` + SessionID string `json:"session_id,omitempty"` + Count int `json:"count,omitempty"` +} diff --git a/website/docs/command-reference.md b/website/docs/command-reference.md index 05981379..d559dea6 100644 --- a/website/docs/command-reference.md +++ b/website/docs/command-reference.md @@ -13,6 +13,7 @@ Complete reference for all `td` commands. | `td create "title" [flags]` | Create issue. Flags: `--type`, `--priority`, `--description`, `--description-file`, `--acceptance`, `--acceptance-file`, `--parent`, `--epic`, `--minor` | | `td list [flags]` | List issues. Flags: `--status`, `--type`, `--priority`, `--epic` | | `td show ` | Display full issue details | +| `td history ` | Chronological timeline of status changes, updates, reviews, logs, handoffs, comments, git snapshots, and linked files. Alias: `td timeline`. Flags: `--limit N` (most recent N), `--json` (UTC timestamps) | | `td update [flags]` | Update fields. Flags: `--title`, `--type`, `--priority`, `--description`, `--description-file`, `--acceptance`, `--acceptance-file`, `--labels` | | `td delete ` | Soft-delete issue | | `td restore ` | Restore soft-deleted issue |