From 12e3c6b0cf617b6936191a3fc48a2a6e29cb56b3 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 10 Jun 2026 19:02:30 +0000 Subject: [PATCH 1/2] feat: add compact changes output for agents --- AGENTS.md | 9 ++- CHANGELOG.md | 2 +- README.md | 3 + cmd/changes.go | 156 +++++++++++++++++++++++++++++++++++++++----- cmd/changes_test.go | 92 ++++++++++++++++++++++++++ docs/api.md | 11 +++- 6 files changed, 251 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dbed9fd..b525468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,11 +134,16 @@ clickup changes --workspace 1234 --since 2026-06-09 # Peek without advancing the saved 'last' timestamp clickup changes --workspace 1234 --no-save +# Agent-friendly / token-light output: keep only IDs, names, status, URL, +# hierarchy IDs/names, and updated timestamps. Limit returned rows while +# preserving full task_count/doc_count totals. +clickup changes --workspace 1234 --since 24h --no-save --compact --limit 25 + # Tasks only, scoped to specific lists/spaces/folders -clickup changes --workspace 1234 --skip-docs --list-ids 111,222 +clickup changes --workspace 1234 --skip-docs --list-ids 111,222 --compact ``` -Output: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. Tasks are filtered server-side (`date_updated_gt`, paginated automatically up to 5,000); docs have no server-side updated filter, so they are filtered client-side by `date_updated` — granularity is per-doc (use `doc page-list` to find which page changed). +Output: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. With `--compact`, the response also includes `task_returned`, `doc_returned`, and `truncated` when a `--limit` caps returned rows. Tasks are filtered server-side (`date_updated_gt`, paginated automatically up to 5,000); docs have no server-side updated filter, so they are filtered client-side by `date_updated` — granularity is per-doc (use `doc page-list` to find which page changed). For agent workflows, prefer `--compact --limit ` to avoid spending tokens on assignees, watchers, tags, custom fields, and other full task metadata. ### Custom Task IDs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb3a09..68d6061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **`clickup changes`** — list tasks and docs updated in a workspace since a point in time. `--since` accepts `last` (tracked per workspace in `~/.clickup-cli-state.json`), durations (`30m`, `24h`, `7d`, `2w`), dates, RFC3339 timestamps, or Unix ms. Tasks are filtered server-side (`date_updated_gt`); docs are filtered client-side by `date_updated` since the v3 Docs API has no updated-since parameter. Supports `--skip-docs`, `--no-save`, and `--space-ids`/`--folder-ids`/`--list-ids` scoping. +- **`clickup changes`** — list tasks and docs updated in a workspace since a point in time. `--since` accepts `last` (tracked per workspace in `~/.clickup-cli-state.json`), durations (`30m`, `24h`, `7d`, `2w`), dates, RFC3339 timestamps, or Unix ms. Tasks are filtered server-side (`date_updated_gt`); docs are filtered client-side by `date_updated` since the v3 Docs API has no updated-since parameter. Supports `--compact` for token-light agent summaries, `--limit` to cap returned rows while keeping total counts, `--skip-docs`, `--no-save`, and `--space-ids`/`--folder-ids`/`--list-ids` scoping. - `date_updated` field on the Doc model (returned by the v3 API but previously dropped during decoding). ## [1.0.0] - 2026-02-16 diff --git a/README.md b/README.md index f0eb609..f1e836c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ clickup task search --workspace 1234567 --assignee 12345 --include-closed # 4b. What changed since my last visit? (tasks + docs, state tracked automatically) clickup changes --workspace 1234567 +# Agent-friendly compact activity feed (lower token usage) +clickup changes --workspace 1234567 --since 24h --no-save --compact --limit 25 + # 5. Track time clickup time-entry start --workspace 1234567 --task abc123 --description "Working on feature" clickup time-entry stop --workspace 1234567 diff --git a/cmd/changes.go b/cmd/changes.go index 9c6e540..b59da50 100644 --- a/cmd/changes.go +++ b/cmd/changes.go @@ -21,14 +21,51 @@ const defaultChangesWindow = 24 * time.Hour const maxChangesPages = 50 type changesResponse struct { - WorkspaceID string `json:"workspace_id"` - Since int64 `json:"since"` - Until int64 `json:"until"` - FirstRun bool `json:"first_run,omitempty"` - TaskCount int `json:"task_count"` - DocCount int `json:"doc_count"` - Tasks []api.Task `json:"tasks"` - Docs []api.Doc `json:"docs"` + WorkspaceID string `json:"workspace_id"` + Since int64 `json:"since"` + Until int64 `json:"until"` + FirstRun bool `json:"first_run,omitempty"` + TaskCount int `json:"task_count"` + DocCount int `json:"doc_count"` + TaskReturned int `json:"task_returned,omitempty"` + DocReturned int `json:"doc_returned,omitempty"` + Truncated bool `json:"truncated,omitempty"` + Tasks []api.Task `json:"tasks"` + Docs []api.Doc `json:"docs"` +} + +type compactChangesResponse struct { + WorkspaceID string `json:"workspace_id"` + Since int64 `json:"since"` + Until int64 `json:"until"` + FirstRun bool `json:"first_run,omitempty"` + TaskCount int `json:"task_count"` + DocCount int `json:"doc_count"` + TaskReturned int `json:"task_returned"` + DocReturned int `json:"doc_returned"` + Truncated bool `json:"truncated,omitempty"` + Tasks []compactTaskChange `json:"tasks"` + Docs []compactDocChange `json:"docs"` +} + +type compactTaskChange struct { + ID string `json:"id"` + CustomID string `json:"custom_id,omitempty"` + Name string `json:"name"` + Status string `json:"status,omitempty"` + URL string `json:"url,omitempty"` + ListID string `json:"list_id,omitempty"` + ListName string `json:"list_name,omitempty"` + FolderID string `json:"folder_id,omitempty"` + FolderName string `json:"folder_name,omitempty"` + SpaceID string `json:"space_id,omitempty"` + DateUpdated string `json:"date_updated,omitempty"` +} + +type compactDocChange struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + DateUpdated int64 `json:"date_updated,omitempty"` } var changesCmd = &cobra.Command{ @@ -121,15 +158,42 @@ by their date_updated field. } } + limit, _ := cmd.Flags().GetInt("limit") + if limit < 0 { + output.PrintError("VALIDATION_ERROR", "--limit must be 0 or greater") + return &exitError{code: 1} + } + + if compact, _ := cmd.Flags().GetBool("compact"); compact { + output.JSON(buildCompactChangesResponse(workspaceID, since, now, firstRun, tasks, docs, limit)) + return nil + } + + taskCount := len(tasks) + docCount := len(docs) + taskReturned := 0 + docReturned := 0 + truncated := false + if limit > 0 { + tasks = limitTasks(tasks, limit) + docs = limitDocs(docs, limit) + taskReturned = len(tasks) + docReturned = len(docs) + truncated = len(tasks) < taskCount || len(docs) < docCount + } + output.JSON(changesResponse{ - WorkspaceID: workspaceID, - Since: since, - Until: now, - FirstRun: firstRun, - TaskCount: len(tasks), - DocCount: len(docs), - Tasks: tasks, - Docs: docs, + WorkspaceID: workspaceID, + Since: since, + Until: now, + FirstRun: firstRun, + TaskCount: taskCount, + DocCount: docCount, + TaskReturned: taskReturned, + DocReturned: docReturned, + Truncated: truncated, + Tasks: tasks, + Docs: docs, }) return nil }, @@ -185,9 +249,69 @@ func parseDuration(s string) (time.Duration, error) { return time.ParseDuration(s) } +func buildCompactChangesResponse(workspaceID string, since, until int64, firstRun bool, tasks []api.Task, docs []api.Doc, limit int) compactChangesResponse { + taskCount := len(tasks) + docCount := len(docs) + tasks = limitTasks(tasks, limit) + docs = limitDocs(docs, limit) + + resp := compactChangesResponse{ + WorkspaceID: workspaceID, + Since: since, + Until: until, + FirstRun: firstRun, + TaskCount: taskCount, + DocCount: docCount, + TaskReturned: len(tasks), + DocReturned: len(docs), + Truncated: len(tasks) < taskCount || len(docs) < docCount, + Tasks: make([]compactTaskChange, 0, len(tasks)), + Docs: make([]compactDocChange, 0, len(docs)), + } + for _, task := range tasks { + resp.Tasks = append(resp.Tasks, compactTaskChange{ + ID: task.ID, + CustomID: task.CustomID, + Name: task.Name, + Status: task.Status.Status, + URL: task.URL, + ListID: task.List.ID, + ListName: task.List.Name, + FolderID: task.Folder.ID, + FolderName: task.Folder.Name, + SpaceID: task.Space.ID, + DateUpdated: task.DateUpdated, + }) + } + for i := range docs { + resp.Docs = append(resp.Docs, compactDocChange{ + ID: docs[i].ID, + Name: docs[i].Name, + DateUpdated: docUpdatedAt(&docs[i]), + }) + } + return resp +} + +func limitTasks(tasks []api.Task, limit int) []api.Task { + if limit <= 0 || len(tasks) <= limit { + return tasks + } + return tasks[:limit] +} + +func limitDocs(docs []api.Doc, limit int) []api.Doc { + if limit <= 0 || len(docs) <= limit { + return docs + } + return docs[:limit] +} + func init() { changesCmd.Flags().String("workspace", "", "Workspace/Team ID") changesCmd.Flags().String("since", "last", "Point in time: last, duration (24h, 7d), date, RFC3339, or Unix ms") + changesCmd.Flags().Bool("compact", false, "Return token-light task/doc summaries for agent workflows") + changesCmd.Flags().Int("limit", 0, "Maximum tasks and docs to return after counting all matches (0 = no limit)") changesCmd.Flags().Bool("skip-docs", false, "Skip checking docs for updates") changesCmd.Flags().Bool("no-save", false, "Don't record this check as the new 'last' timestamp") changesCmd.Flags().StringSlice("space-ids", nil, "Limit task changes to space IDs") diff --git a/cmd/changes_test.go b/cmd/changes_test.go index d09c615..0f2a4cd 100644 --- a/cmd/changes_test.go +++ b/cmd/changes_test.go @@ -80,6 +80,98 @@ func TestChangesCommand(t *testing.T) { } } +func TestChangesCompactOutput(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + docsJSON := `{"docs":[{"id":"doc-new","name":"Fresh","date_updated":9000000000000,"creator":{"id":1},"visibility":"private"}]}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/v2/team/") { + fmt.Fprint(w, `{"tasks":[{"id":"task1","name":"Updated Task","status":{"status":"in progress","color":"#fff","type":"custom"},"date_updated":"9000000000000","url":"https://app.clickup.com/t/task1","list":{"id":"list1","name":"Sprint"},"folder":{"id":"folder1","name":"Product"},"space":{"id":"space1"},"assignees":[{"id":1,"username":"Agent"}],"custom_fields":[{"id":"field1","name":"Huge field"}]}]}`) + return + } + fmt.Fprint(w, docsJSON) + })) + t.Cleanup(server.Close) + + out, err := runCommand(t, server.URL, "changes", "--since", "5000", "--compact") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "custom_fields") || strings.Contains(out, "assignees") || strings.Contains(out, "creator") { + t.Fatalf("compact output should omit token-heavy fields, got: %s", out) + } + + var resp struct { + TaskCount int `json:"task_count"` + TaskReturned int `json:"task_returned"` + DocCount int `json:"doc_count"` + DocReturned int `json:"doc_returned"` + Tasks []struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + URL string `json:"url"` + ListID string `json:"list_id"` + ListName string `json:"list_name"` + FolderName string `json:"folder_name"` + SpaceID string `json:"space_id"` + DateUpdated string `json:"date_updated"` + } `json:"tasks"` + Docs []struct { + ID string `json:"id"` + Name string `json:"name"` + DateUpdated int64 `json:"date_updated"` + } `json:"docs"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + if resp.TaskCount != 1 || resp.TaskReturned != 1 || resp.DocCount != 1 || resp.DocReturned != 1 { + t.Fatalf("unexpected counts: %+v", resp) + } + task := resp.Tasks[0] + if task.ID != "task1" || task.Status != "in progress" || task.ListName != "Sprint" || task.FolderName != "Product" || task.SpaceID != "space1" || task.URL == "" || task.DateUpdated == "" { + t.Errorf("unexpected compact task: %+v", task) + } + if resp.Docs[0].ID != "doc-new" || resp.Docs[0].Name != "Fresh" || resp.Docs[0].DateUpdated == 0 { + t.Errorf("unexpected compact doc: %+v", resp.Docs[0]) + } +} + +func TestChangesLimitCompactsReturnedItemsWithoutChangingCounts(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/v2/team/") { + fmt.Fprint(w, `{"tasks":[{"id":"task1","name":"One"},{"id":"task2","name":"Two"},{"id":"task3","name":"Three"}]}`) + return + } + fmt.Fprint(w, `{"docs":[{"id":"doc1","name":"One","date_updated":9000000000000},{"id":"doc2","name":"Two","date_updated":9000000000000}]}`) + })) + t.Cleanup(server.Close) + + out, err := runCommand(t, server.URL, "changes", "--since", "5000", "--compact", "--limit", "1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var resp struct { + TaskCount int `json:"task_count"` + TaskReturned int `json:"task_returned"` + DocCount int `json:"doc_count"` + DocReturned int `json:"doc_returned"` + Truncated bool `json:"truncated"` + Tasks []struct{ ID string } `json:"tasks"` + Docs []struct{ ID string } `json:"docs"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + if resp.TaskCount != 3 || resp.TaskReturned != 1 || resp.DocCount != 2 || resp.DocReturned != 1 || !resp.Truncated { + t.Fatalf("expected full counts but limited returned items, got %+v", resp) + } + if resp.Tasks[0].ID != "task1" || resp.Docs[0].ID != "doc1" { + t.Errorf("limit should preserve updated ordering, got tasks=%+v docs=%+v", resp.Tasks, resp.Docs) + } +} + func TestChangesTaskQueryParams(t *testing.T) { t.Setenv("HOME", t.TempDir()) var taskQuery string diff --git a/docs/api.md b/docs/api.md index 62d8acd..db83c9f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -521,23 +521,28 @@ With `--since last` (the default), the timestamp of each run is recorded per wor |------|------|---------|-----------|-------------| | `--workspace` | string | *(global)* | `team_id` / `workspace_id` (path) | Workspace ID | | `--since` | string | `last` | `date_updated_gt` (query, tasks) | `last`, a duration (`30m`, `24h`, `7d`, `2w`), a date (`2026-06-09`), an RFC3339 timestamp, or Unix ms | +| `--compact` | bool | `false` | — | Return token-light task/doc summaries for agent workflows | +| `--limit` | int | `0` | — | Maximum tasks and docs to return after counting all matches (`0` = no limit) | | `--skip-docs` | bool | `false` | — | Skip checking docs for updates | | `--no-save` | bool | `false` | — | Don't record this check as the new `last` timestamp | | `--space-ids` | string[] | — | `space_ids[]` (query) | Limit task changes to space IDs | | `--folder-ids` | string[] | — | `folder_ids[]` (query) | Limit task changes to folder IDs | | `--list-ids` | string[] | — | `list_ids[]` (query) | Limit task changes to list IDs | -Output shape: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. Task pagination is handled automatically (up to 5,000 tasks per run). +Output shape: `{"workspace_id", "since", "until", "first_run", "task_count", "doc_count", "tasks": [...], "docs": [...]}`. Task pagination is handled automatically (up to 5,000 tasks per run). With `--compact`, each task is reduced to `id`, `custom_id`, `name`, `status`, `url`, list/folder/space identifiers, and `date_updated`; docs are reduced to `id`, `name`, and `date_updated`. When `--limit` is set, `task_count`/`doc_count` still report total matches, while `task_returned`/`doc_returned` and `truncated` describe the capped payload. ```bash # What changed since I last checked? (tracks state automatically) clickup changes --workspace 1234567 # What changed in the last 2 days, without touching saved state -clickup changes --workspace 1234567 --since 2d +clickup changes --workspace 1234567 --since 2d --no-save + +# Low-token agent summary for recent changes +clickup changes --workspace 1234567 --since 24h --no-save --compact --limit 25 # Tasks only, scoped to two lists -clickup changes --workspace 1234567 --since 2026-06-09 --skip-docs --list-ids 111,222 +clickup changes --workspace 1234567 --since 2026-06-09 --skip-docs --list-ids 111,222 --compact ``` --- From 530c7384729c6ad02be4a228876ec6c66ef171c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 19:35:17 +0000 Subject: [PATCH 2/2] fix: avoid struct copy in compact changes loop (gocritic rangeValCopy) Index into the tasks slice instead of ranging by value, which copied 672 bytes per iteration and failed the lint CI job. https://claude.ai/code/session_01TnPGFjB1wd8dffJdR2WFQP --- cmd/changes.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/changes.go b/cmd/changes.go index b59da50..f6ede8f 100644 --- a/cmd/changes.go +++ b/cmd/changes.go @@ -268,7 +268,8 @@ func buildCompactChangesResponse(workspaceID string, since, until int64, firstRu Tasks: make([]compactTaskChange, 0, len(tasks)), Docs: make([]compactDocChange, 0, len(docs)), } - for _, task := range tasks { + for i := range tasks { + task := &tasks[i] resp.Tasks = append(resp.Tasks, compactTaskChange{ ID: task.ID, CustomID: task.CustomID,