From 6a59df2fc45df9e371205433d6a5d6b3829dd1bd Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 13 Oct 2026 09:39:17 -0500 Subject: [PATCH 1/3] Fix mise Go pin to match go.mod 1.27.2 The vulnerability upgrade bumped go.mod to Go 1.27.2 but left the mise toolchain pinned to 1.27.1, so every go tool invocation mixed the two toolchains and failed. Syncing the pin and its lockfile restores the fixers, tests, and linters hooks. --- mise.lock | 30 +++++++++++++++--------------- mise.toml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/mise.lock b/mise.lock index 43a4dea..1fd7b65 100644 --- a/mise.lock +++ b/mise.lock @@ -40,36 +40,36 @@ url = "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8 url_api = "https://api.github.com/repos/gitleaks/gitleaks/releases/assets/378333178" [[tools.go]] -version = "1.27.1" +version = "1.27.2" backend = "core:go" [tools.go."platforms.linux-arm64"] -checksum = "sha256:3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec" -url = "https://dl.google.com/go/go1.27.1.linux-arm64.tar.gz" +checksum = "sha256:94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8" +url = "https://dl.google.com/go/go1.27.2.linux-arm64.tar.gz" [tools.go."platforms.linux-arm64-musl"] -checksum = "sha256:3450b45a3f9ee8568792736a5c5e70a1f2e9b36c35a8f74958c03e51d7d92bec" -url = "https://dl.google.com/go/go1.27.1.linux-arm64.tar.gz" +checksum = "sha256:94f3e30b8e374bc285e7dadc11e0865726b9bc6e85b841ccceaabc0214c6b7c8" +url = "https://dl.google.com/go/go1.27.2.linux-arm64.tar.gz" [tools.go."platforms.linux-x64"] -checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" -url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +checksum = "sha256:ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5" +url = "https://dl.google.com/go/go1.27.2.linux-amd64.tar.gz" [tools.go."platforms.linux-x64-musl"] -checksum = "sha256:63d339f0da5ab53635a56f2490a7984dfe12dfcff22ad749f63edaf590168445" -url = "https://dl.google.com/go/go1.27.1.linux-amd64.tar.gz" +checksum = "sha256:ecbadb99091a3f46e31f5f934b068b1864eafa7995211b39eaddf76996045fe5" +url = "https://dl.google.com/go/go1.27.2.linux-amd64.tar.gz" [tools.go."platforms.macos-arm64"] -checksum = "sha256:ee215d57e0ec269c60cc9ceca68e6bda321ba9ee5afe24f4b0988703c2d87d12" -url = "https://dl.google.com/go/go1.27.1.darwin-arm64.tar.gz" +checksum = "sha256:76812b213b1b2302c978d28fa52fa92d541704b9e7d9d5db8002c50e4018c4c5" +url = "https://dl.google.com/go/go1.27.2.darwin-arm64.tar.gz" [tools.go."platforms.macos-x64"] -checksum = "sha256:8f8f52c6649542cf027bbc9b9c68d1ec042f9f34808a40413f0b8b3f66f3caa4" -url = "https://dl.google.com/go/go1.27.1.darwin-amd64.tar.gz" +checksum = "sha256:587b59182488b23aa6e5fc25110405a3e0e5b38ed2f5b2f46ed13c32aee356fe" +url = "https://dl.google.com/go/go1.27.2.darwin-amd64.tar.gz" [tools.go."platforms.windows-x64"] -checksum = "sha256:a3911b5e0e1b1053f25ed0675f4c1c6aad1e2bfcf253df2b9be4caabd2edd95d" -url = "https://dl.google.com/go/go1.27.1.windows-amd64.zip" +checksum = "sha256:1314008898bd40df77af4b014f777f08873dbdfbcd3d92308728ee03304fe04f" +url = "https://dl.google.com/go/go1.27.2.windows-amd64.zip" [[tools."go:github.com/Antonboom/testifylint"]] version = "1.6.4" diff --git a/mise.toml b/mise.toml index eee7fbb..24f261c 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -go = "1.27.1" +go = "1.27.2" ##################### # Development Tasks # From b0f85bcf6e0e52cf33602297181b4ea4bb528c09 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 13 Oct 2026 09:39:17 -0500 Subject: [PATCH 2/3] Refactor: Extract line-numbered todo entries from progress counting Counting checkboxes and emitting structured JSON both need the same checkbox scan. Parsing once into entries with line numbers, indent widths, and titles prepares the --json hierarchy without changing progress behavior. --- internal/timber/todo_progress.go | 82 ++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/internal/timber/todo_progress.go b/internal/timber/todo_progress.go index ae8f6ef..08aefd8 100644 --- a/internal/timber/todo_progress.go +++ b/internal/timber/todo_progress.go @@ -39,40 +39,94 @@ func readTodoProgress(gitDir string) (todoProgress, error) { return progress, parseErr } +// todoEntry is one Markdown checkbox in TODO.md. ID is the 1-based line +// number in the file, and Indent is the leading-whitespace width used to +// resolve parent-child relationships. +type todoEntry struct { + ID int + Indent int + Title string + Complete bool +} + // parseTodoProgress counts checklist items line by line. Items may be nested, // so leading whitespace is ignored, and only lines whose first non-space // characters form a checkbox count toward the totals. func parseTodoProgress(reader io.Reader) (todoProgress, error) { + entries, err := parseTodoEntries(reader) + if err != nil { + return todoProgress{}, err + } var progress todoProgress + progress.Total = len(entries) + for _, entry := range entries { + if entry.Complete { + progress.Done++ + } + } + return progress, nil +} + +// parseTodoEntries returns one entry per checkbox line in file order. +// Non-checkbox lines are skipped but still advance the 1-based line +// numbers used as IDs. +func parseTodoEntries(reader io.Reader) ([]todoEntry, error) { + var entries []todoEntry scanner := bufio.NewScanner(reader) + lineNumber := 0 for scanner.Scan() { - checked, ok := todoItemChecked(strings.TrimSpace(scanner.Text())) + lineNumber++ + line := scanner.Text() + checked, title, ok := todoItemChecked(strings.TrimSpace(line)) if !ok { continue } - progress.Total++ - if checked { - progress.Done++ - } + entries = append(entries, todoEntry{ + ID: lineNumber, + Indent: todoIndentWidth(line), + Title: title, + Complete: checked, + }) } if err := scanner.Err(); err != nil { - return todoProgress{}, fmt.Errorf("read TODO.md: %w", err) + return nil, fmt.Errorf("read TODO.md: %w", err) } - return progress, nil + return entries, nil +} + +// todoIndentWidth measures leading whitespace with tabs expanded so +// mixed indentation still nests deterministically. +func todoIndentWidth(line string) int { + width := 0 + for _, char := range line { + switch char { + case ' ': + width++ + case '\t': + width += 8 - (width % 8) + default: + return width + } + } + return width } -// todoItemChecked reports whether a trimmed line is a Markdown checkbox and -// whether that checkbox is checked. -func todoItemChecked(line string) (checked bool, ok bool) { +// todoItemChecked reports whether a trimmed line is a Markdown checkbox, +// whether that checkbox is checked, and the checkbox title. +func todoItemChecked(line string) (checked bool, title string, ok bool) { item, found := strings.CutPrefix(line, "- ") if !found { - return false, false + return false, "", false } + var marker string switch { case strings.HasPrefix(item, "[ ]"): - return false, true + marker = "[ ]" case strings.HasPrefix(item, "[x]"), strings.HasPrefix(item, "[X]"): - return true, true + checked = true + marker = item[:3] + default: + return false, "", false } - return false, false + return checked, strings.TrimSpace(strings.TrimPrefix(item[len(marker):], " ")), true } From 009c2b75f804e2710aaa353984500ffce71501eb Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Tue, 13 Oct 2026 09:39:17 -0500 Subject: [PATCH 3/3] Add --json output to timber todo with summary and hierarchy Emits the TODO.md checklist as JSON with a summary of incomplete and complete counts and a todos array. Each item uses its 1-based line number as id, links indentation through depends_on to the parent id, and reports its complete state alongside its title. --- internal/timber/todo.go | 8 +- internal/timber/todo_json.go | 103 +++++++++++++++++++++++ internal/timber/todo_json_test.go | 132 ++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 internal/timber/todo_json.go create mode 100644 internal/timber/todo_json_test.go diff --git a/internal/timber/todo.go b/internal/timber/todo.go index 04c5cb8..15e6793 100644 --- a/internal/timber/todo.go +++ b/internal/timber/todo.go @@ -27,6 +27,7 @@ type todoCommandOptions struct { pathOnly bool installSkill bool force bool + jsonOutput bool } func NewTodoCommand(runtime Runtime) *cobra.Command { @@ -42,7 +43,8 @@ func NewTodoCommand(runtime Runtime) *cobra.Command { command.Flags().BoolVar(&options.pathOnly, "path", false, "Print the TODO.md path instead of opening it") command.Flags().BoolVar(&options.installSkill, "install-skill", false, "Install the timber-todo skill to ~/.agents/skills") command.Flags().BoolVarP(&options.force, "force", "f", false, "Overwrite an existing installed skill") - command.MarkFlagsMutuallyExclusive("path", "install-skill") + command.Flags().BoolVar(&options.jsonOutput, "json", false, "Print TODO.md items as JSON instead of opening the file") + command.MarkFlagsMutuallyExclusive("path", "install-skill", "json") return command } @@ -73,6 +75,10 @@ func (x *todoCommandOptions) Execute(command *cobra.Command, args []string) erro return err } + if x.jsonOutput { + return x.reportTodoJSON(command, gitDir) + } + editorLine, err := editorCommandLine(x.runtime) if err != nil { return err diff --git a/internal/timber/todo_json.go b/internal/timber/todo_json.go new file mode 100644 index 0000000..1da6d55 --- /dev/null +++ b/internal/timber/todo_json.go @@ -0,0 +1,103 @@ +package timber + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + json "encoding/json/v2" + + "github.com/spf13/cobra" +) + +// todoJSONSummary counts TODO.md items by status. +type todoJSONSummary struct { + Incomplete int `json:"incomplete"` + Complete int `json:"complete"` +} + +// todoJSONItem is one TODO.md checkbox. ID is the 1-based line number in +// TODO.md, and DependsOn is the ID of the nearest preceding checkbox with +// less indentation, or null for a top-level item. +type todoJSONItem struct { + ID int `json:"id"` + Title string `json:"title"` + DependsOn *int `json:"depends_on"` + Complete bool `json:"complete"` +} + +// todoJSONOutput is the `todo --json` document. +type todoJSONOutput struct { + Summary todoJSONSummary `json:"summary"` + Todos []todoJSONItem `json:"todos"` +} + +// readTodoJSON parses a worktree's TODO.md into its JSON document. A missing +// TODO.md is an empty checklist, not an error, because `timber todo` creates +// the file lazily. +func readTodoJSON(gitDir string) (todoJSONOutput, error) { + contents, err := os.ReadFile(todoFilePath(gitDir)) + if errors.Is(err, fs.ErrNotExist) { + return todoJSONOutput{Todos: []todoJSONItem{}}, nil + } + if err != nil { + return todoJSONOutput{}, fmt.Errorf("open %s: %w", todoFilePath(gitDir), err) + } + return todoJSONFromContents(string(contents)), nil +} + +// todoJSONFromContents converts raw TODO.md text to its JSON document. +func todoJSONFromContents(contents string) todoJSONOutput { + entries, err := parseTodoEntries(strings.NewReader(contents)) + if err != nil { + // parseTodoEntries only fails on scanner I/O errors, which cannot + // happen for an in-memory string; fall through with no items. + return todoJSONOutput{Todos: []todoJSONItem{}} + } + return todoEntriesToJSON(entries) +} + +// todoEntriesToJSON resolves indentation into depends_on links and counts +// the summary. Each item's parent is the nearest preceding item with less +// indentation, tracked with a stack. +func todoEntriesToJSON(entries []todoEntry) todoJSONOutput { + output := todoJSONOutput{Todos: []todoJSONItem{}} + var stack []todoEntry + for _, entry := range entries { + for len(stack) > 0 && stack[len(stack)-1].Indent >= entry.Indent { + stack = stack[:len(stack)-1] + } + item := todoJSONItem{ + ID: entry.ID, + Title: entry.Title, + Complete: entry.Complete, + } + if len(stack) > 0 { + parentID := stack[len(stack)-1].ID + item.DependsOn = &parentID + } + output.Todos = append(output.Todos, item) + stack = append(stack, entry) + if entry.Complete { + output.Summary.Complete++ + } else { + output.Summary.Incomplete++ + } + } + return output +} + +func (x *todoCommandOptions) reportTodoJSON(command *cobra.Command, gitDir string) error { + output, err := readTodoJSON(gitDir) + if err != nil { + return err + } + encoded, err := json.Marshal(output) + if err != nil { + return fmt.Errorf("encode TODO.md as JSON: %w", err) + } + _, err = fmt.Fprintln(command.OutOrStdout(), string(encoded)) + return err +} diff --git a/internal/timber/todo_json_test.go b/internal/timber/todo_json_test.go new file mode 100644 index 0000000..ecb88ee --- /dev/null +++ b/internal/timber/todo_json_test.go @@ -0,0 +1,132 @@ +package timber + +import ( + json "encoding/json/v2" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTodoJSONNestedHierarchy(t *testing.T) { + t.Parallel() + + contents := strings.Join([]string{ + "# TODO", + "- [ ] Top task", + " - [ ] Child task", + " - [x] Grandchild task", + "- [X] Done top", + "- plain bullet", + }, "\n") + "\n" + + output := todoJSONFromContents(contents) + + assert.Equal(t, todoJSONSummary{Incomplete: 2, Complete: 2}, output.Summary) + require.Len(t, output.Todos, 4) + + assert.Equal(t, 2, output.Todos[0].ID) + assert.Equal(t, "Top task", output.Todos[0].Title) + assert.Nil(t, output.Todos[0].DependsOn) + assert.False(t, output.Todos[0].Complete) + + require.NotNil(t, output.Todos[1].DependsOn) + assert.Equal(t, 2, *output.Todos[1].DependsOn) + assert.Equal(t, 3, output.Todos[1].ID) + assert.Equal(t, "Child task", output.Todos[1].Title) + + require.NotNil(t, output.Todos[2].DependsOn) + assert.Equal(t, 3, *output.Todos[2].DependsOn) + assert.Equal(t, 4, output.Todos[2].ID) + assert.True(t, output.Todos[2].Complete) + + assert.Equal(t, 5, output.Todos[3].ID) + assert.Nil(t, output.Todos[3].DependsOn) + assert.True(t, output.Todos[3].Complete) +} + +func TestTodoJSONSiblingReturnsToParent(t *testing.T) { + t.Parallel() + + contents := "- [ ] Parent\n - [ ] First\n - [ ] Second\n- [ ] Other\n" + output := todoJSONFromContents(contents) + + require.Len(t, output.Todos, 4) + assert.Nil(t, output.Todos[0].DependsOn) + require.NotNil(t, output.Todos[1].DependsOn) + assert.Equal(t, 1, *output.Todos[1].DependsOn) + require.NotNil(t, output.Todos[2].DependsOn) + assert.Equal(t, 1, *output.Todos[2].DependsOn) + assert.Nil(t, output.Todos[3].DependsOn) + assert.Equal(t, todoJSONSummary{Incomplete: 4}, output.Summary) +} + +func TestTodoJSONEmptyContents(t *testing.T) { + t.Parallel() + + output := todoJSONFromContents("# TODO\nno items\n") + assert.Equal(t, todoJSONSummary{}, output.Summary) + assert.NotNil(t, output.Todos) + assert.Empty(t, output.Todos) +} + +func TestTodoJSONCommandEmitsFileAsJSON(t *testing.T) { + t.Parallel() + + const branchName = "feature/todo-json" + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + + worktreePath := testRepository.worktreePath(branchName) + writeTodoFile(t, todoPathForWorktree(t, worktreePath), strings.Join([]string{ + "- [ ] Top task", + " - [x] Child task", + "- [ ] Other", + }, "\n")+"\n") + + // EDITOR=false would fail if the editor were launched. + runtime := withTestEnvironment(testRepository.runtime, "EDITOR=false") + result := runTimberFromWithRuntime(t, runtime, worktreePath, "todo", "--json") + require.NoError(t, result.err, result.stderr) + + var output todoJSONOutput + require.NoError(t, json.Unmarshal([]byte(result.stdout), &output)) + assert.Equal(t, todoJSONSummary{Incomplete: 2, Complete: 1}, output.Summary) + require.Len(t, output.Todos, 3) + assert.Equal(t, "Top task", output.Todos[0].Title) + assert.False(t, output.Todos[0].Complete) + assert.Nil(t, output.Todos[0].DependsOn) + require.NotNil(t, output.Todos[1].DependsOn) + assert.Equal(t, 1, *output.Todos[1].DependsOn) + assert.True(t, output.Todos[1].Complete) + assert.Nil(t, output.Todos[2].DependsOn) +} + +func TestTodoJSONCommandTreatsMissingFileAsEmpty(t *testing.T) { + t.Parallel() + + const branchName = "feature/todo-json-missing" + testRepository := newTestRepository(t) + require.NoError(t, testRepository.runTimber(t, "create", at(testRepoName, branchName)).err) + + worktreePath := testRepository.worktreePath(branchName) + runtime := withTestEnvironment(testRepository.runtime, "EDITOR=false") + result := runTimberFromWithRuntime(t, runtime, worktreePath, "todo", "--json") + require.NoError(t, result.err, result.stderr) + + var output todoJSONOutput + require.NoError(t, json.Unmarshal([]byte(result.stdout), &output)) + assert.Equal(t, todoJSONSummary{}, output.Summary) + assert.Empty(t, output.Todos) +} + +func TestTodoJSONRejectsPathCombination(t *testing.T) { + t.Parallel() + + testRepository := newTestRepository(t) + runtime := withTestEnvironment(testRepository.runtime, "EDITOR=false") + + result := runTimberFromWithRuntime(t, runtime, testRepository.home, "todo", "--path", "--json") + require.Error(t, result.err) +}