From b76863170a5a7c55b64696ab3780df7ea3737b8e Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 14:07:43 +0800 Subject: [PATCH 1/7] docs: define task compact field projection --- .../2026-07-30-task-compact-fields-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-task-compact-fields-design.md diff --git a/docs/superpowers/specs/2026-07-30-task-compact-fields-design.md b/docs/superpowers/specs/2026-07-30-task-compact-fields-design.md new file mode 100644 index 0000000000..29c07755d1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-task-compact-fields-design.md @@ -0,0 +1,111 @@ +# Task Compact Fields Design + +## Goal + +Expose the task title, members, start time, due time, and completion state when +task shortcuts already receive a Task entity but currently discard those +fields while building compact output. + +The change must remain backward compatible: existing JSON fields, paths, and +types stay unchanged, and the implementation must not add API requests. + +## Compatibility Rules + +- Do not remove, rename, or change the type of an existing output field. +- Add a field only when the command does not already expose the same meaning. +- Reuse Task API names and values for newly projected entity fields: + `summary`, `members`, `start`, `due`, and `status`. +- Preserve existing compact aliases such as `due_at`, `completed`, and + `completed_at`; do not emit a second field for the same meaning in that + command. +- Preserve current fallback behavior. In particular, a `+search` item whose + detail request fails continues to contain only `guid` and `url`. +- Empty API values are transcribed faithfully. No synthetic member, time, or + status value is invented. + +## Affected Outputs + +### Resource-reference write results + +When their existing API response contains `data.task`, extend the current +`guid`/`url` projection with `summary`, `members`, `start`, `due`, and `status`: + +- `task +create` +- `task +reopen` +- `task +assign` +- `task +followers` +- `task +reminder` + +`task +complete` already returns `status`; add the missing `summary`, `members`, +`start`, and `due` fields without changing `completed_at` or +`already_completed`. + +### Nested write results + +Extend each successful Task item using the same projection: + +- `task +update`: `tasks[]` +- `task +tasklist-create`: `created_tasks[]` +- `task +tasklist-task-add`: `successful_tasks[]` + +Batch failure item schemas remain unchanged. + +### Read summaries + +Add only missing meanings to avoid redundant fields: + +| Command | Existing equivalents kept | Newly projected fields | +| --- | --- | --- | +| `task +search` | `summary`, `due_at`, `completed_at` | `members`, `start`, `status` | +| `task +get-my-tasks` | `summary`, `due_at`, `completed` | `members`, `start` | +| `task +get-related-tasks` | `summary`, `members`, `status` | `start`, `due` | + +The existing command-specific formatted time fields remain unchanged. + +## Excluded Outputs + +- Generated raw API commands already return the full upstream entity. +- `task +set-ancestor` is excluded because its endpoint does not provide a + usable Task entity; fetching one would introduce an extra request and new + post-mutation failure semantics. +- Comment, attachment, and tasklist outputs represent different entity types. +- Tasklist-level fields in `task +tasklist-create` remain unchanged; only its + `created_tasks[]` items are Task entities. + +## Data Flow + +At the API boundary, existing shortcut code receives loose Task maps. A shared +projection helper copies the requested upstream fields into the existing +compact result map. Member data is preserved as returned, including `role`, so +callers can identify responsible users with `role == "assignee"` without losing +followers. + +Every affected command uses Task data it already has: + +- create/update/complete/reopen/member/tasklist mutations use their existing + Task response; +- reminder uses its existing pre-mutation Task GET because reminder mutations + do not change the projected fields; +- list-related and my-tasks use the returned list items; +- search keeps its existing per-hit detail query. + +No new GET or other API call is introduced. + +## Error and Fallback Behavior + +Projection is best-effort and cannot turn a successful API operation into an +error. Missing upstream fields are omitted rather than guessed. Existing typed +API errors, partial-failure envelopes, exit codes, and per-item failure objects +remain unchanged. + +## Testing + +- Add focused helper tests that pin faithful projection and omission of absent + fields. +- Extend command tests to assert newly exposed fields at the final JSON paths. +- Cover root write results, nested batch results, and all three read-summary + variants. +- Keep existing fallback tests green, especially search detail failure and + partial batch failures. +- Run task package tests, `gofmt`, and the repository-required unit test gate + before completion. From 14004c51120ddea8d359b63aafd0e0e9770a2f93 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 14:39:42 +0800 Subject: [PATCH 2/7] docs: add task compact fields implementation plan --- .../plans/2026-07-30-task-compact-fields.md | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-task-compact-fields.md diff --git a/docs/superpowers/plans/2026-07-30-task-compact-fields.md b/docs/superpowers/plans/2026-07-30-task-compact-fields.md new file mode 100644 index 0000000000..279a0a47c6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-task-compact-fields.md @@ -0,0 +1,350 @@ +# Task Compact Fields Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve existing task shortcut output contracts while projecting task title, members, start, due, and completion state from Task entities the commands already receive. + +**Architecture:** Add one typed-field projection helper at the task output boundary, then call it from existing compact-output builders. Each command selects only missing semantic fields, so existing aliases such as `due_at`, `completed`, and `completed_at` remain unchanged and no redundant fields or new API calls are introduced. + +**Tech Stack:** Go, Cobra shortcut runtime, `internal/httpmock`, standard `testing`, `make unit-test` + +--- + +## File Map + +- Create `shortcuts/task/task_output.go`: typed task field identifiers and faithful map-to-map projection helper. +- Create `shortcuts/task/task_output_test.go`: helper contract plus root write-output command contracts. +- Modify `shortcuts/task/task_query_helpers.go`: add missing fields to search and related-task projections. +- Modify `shortcuts/task/task_query_helpers_test.go`: pin search/related projection fields and absent-field behavior. +- Modify `shortcuts/task/task_get_my_tasks.go`: add `members` and `start` to my-task items. +- Modify `shortcuts/task/task_get_my_tasks_test.go`: pin final JSON paths. +- Modify `shortcuts/task/task_get_related_tasks_test.go`: pin `start` and `due` in command output. +- Modify `shortcuts/task/shortcuts.go`, `task_reopen.go`, `task_assign.go`, `task_followers.go`, `task_reminder.go`, and `task_complete.go`: project fields already present in each Task response. +- Modify the corresponding command tests to pin root output fields. +- Modify `shortcuts/task/task_update.go`, `tasklist_create.go`, and `tasklist_add_task.go`: project fields into successful nested Task items. +- Modify their tests to pin nested output paths while preserving failure schemas. + +### Task 1: Shared faithful projection helper + +**Files:** +- Create: `shortcuts/task/task_output.go` +- Create: `shortcuts/task/task_output_test.go` + +- [ ] **Step 1: Write the failing helper tests** + +Add tests that call the not-yet-defined helper with a full source entity and assert exact preservation, then call it with absent fields and assert those keys are omitted: + +```go +func TestProjectTaskFields(t *testing.T) { + task := map[string]interface{}{ + "summary": "Ship compact fields", + "members": []interface{}{map[string]interface{}{ + "id": "ou_owner", "name": "Owner", "type": "user", "role": "assignee", + }}, + "start": map[string]interface{}{"timestamp": "1000", "is_all_day": false}, + "due": map[string]interface{}{"timestamp": "2000", "is_all_day": false}, + "status": "todo", + } + out := map[string]interface{}{"guid": "task-1"} + + projectTaskFields(out, task, standardTaskOutputFields...) + + for _, field := range standardTaskOutputFields { + key := string(field) + if !reflect.DeepEqual(out[key], task[key]) { + t.Fatalf("%s = %#v, want %#v", key, out[key], task[key]) + } + } +} + +func TestProjectTaskFieldsOmitsAbsentFields(t *testing.T) { + out := map[string]interface{}{} + projectTaskFields(out, map[string]interface{}{"summary": "Only title"}, standardTaskOutputFields...) + if _, ok := out["members"]; ok { + t.Fatalf("members unexpectedly projected: %#v", out) + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: `go test ./shortcuts/task -run '^TestProjectTaskFields'` + +Expected: build failure because `projectTaskFields` and `standardTaskOutputFields` do not exist. + +- [ ] **Step 3: Add the minimal typed-field helper** + +```go +package task + +type taskOutputField string + +const ( + taskOutputSummary taskOutputField = "summary" + taskOutputMembers taskOutputField = "members" + taskOutputStart taskOutputField = "start" + taskOutputDue taskOutputField = "due" + taskOutputStatus taskOutputField = "status" +) + +var standardTaskOutputFields = []taskOutputField{ + taskOutputSummary, + taskOutputMembers, + taskOutputStart, + taskOutputDue, + taskOutputStatus, +} + +func projectTaskFields(dst, task map[string]interface{}, fields ...taskOutputField) { + for _, field := range fields { + key := string(field) + if value, ok := task[key]; ok { + dst[key] = value + } + } +} +``` + +- [ ] **Step 4: Run helper tests and verify GREEN** + +Run: `go test ./shortcuts/task -run '^TestProjectTaskFields'` + +Expected: PASS. + +- [ ] **Step 5: Commit the helper** + +```bash +git add shortcuts/task/task_output.go shortcuts/task/task_output_test.go +git commit -m "feat: add task output field projector" +``` + +### Task 2: Read-summary outputs + +**Files:** +- Modify: `shortcuts/task/task_query_helpers.go:136-201` +- Modify: `shortcuts/task/task_query_helpers_test.go:68-225` +- Modify: `shortcuts/task/task_get_my_tasks.go:199-225` +- Modify: `shortcuts/task/task_get_my_tasks_test.go:13-101` +- Modify: `shortcuts/task/task_get_related_tasks_test.go:95-185` + +- [ ] **Step 1: Extend read-output tests first** + +Add `members`, `start`, `due`, and `status` fixtures to the direct helper tests. Assert: + +```go +projected := outputTaskSummary(task) +if !reflect.DeepEqual(projected["members"], task["members"]) || + !reflect.DeepEqual(projected["start"], task["start"]) || + projected["status"] != "todo" { + t.Fatalf("search projection lost compact fields: %#v", projected) +} +if _, duplicated := projected["due"]; duplicated { + t.Fatalf("search projection duplicated due alongside due_at: %#v", projected) +} +``` + +For related tasks, assert `start` and `due` are present. In the JSON execution fixtures for `+get-my-tasks`, add `members` and `start` and assert their serialized paths while retaining `due_at` and `completed`. In `+get-related-tasks`, add `start` and `due` to the fixture and expected JSON fragments. + +- [ ] **Step 2: Run focused read tests and verify RED** + +Run: + +```bash +go test ./shortcuts/task -run 'Test(OutputTaskSummary|OutputRelatedTaskAndTimeRangeFilter|GetMyTasks_LocalTimeFormatting|GetRelatedTasks_Execute)' +``` + +Expected: FAIL because the new fields are absent. + +- [ ] **Step 3: Add only the missing projections** + +After each existing output map is built: + +```go +// outputTaskSummary: due_at/completed_at already carry those meanings. +projectTaskFields(out, task, taskOutputMembers, taskOutputStart, taskOutputStatus) + +// outputRelatedTask: members/status already exist. +projectTaskFields(out, task, taskOutputStart, taskOutputDue) + +// GetMyTasks outputItem: due_at/completed already exist. +projectTaskFields(outputItem, item, taskOutputMembers, taskOutputStart) +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the command from Step 2. + +Expected: PASS. + +- [ ] **Step 5: Commit read-summary behavior** + +```bash +git add shortcuts/task/task_query_helpers.go shortcuts/task/task_query_helpers_test.go shortcuts/task/task_get_my_tasks.go shortcuts/task/task_get_my_tasks_test.go shortcuts/task/task_get_related_tasks_test.go +git commit -m "feat: expose missing fields in task summaries" +``` + +### Task 3: Root write-result outputs + +**Files:** +- Modify: `shortcuts/task/shortcuts.go:234-250` +- Modify: `shortcuts/task/task_reopen.go:42-59` +- Modify: `shortcuts/task/task_assign.go:84-95` +- Modify: `shortcuts/task/task_followers.go:85-96` +- Modify: `shortcuts/task/task_reminder.go:75-173` +- Modify: `shortcuts/task/task_complete.go:86-103` +- Test: `shortcuts/task/task_output_test.go` +- Test: `shortcuts/task/task_complete_test.go` +- Test: `shortcuts/task/task_reminder_test.go` + +- [ ] **Step 1: Write root output contract tests** + +Use `taskShortcutTestFactory`, `warmTenantToken`, and `httpmock.Stub` to run each shortcut with a Task response containing the five upstream fields. Decode the JSON envelope and assert existing `guid`/`url` plus the new fields. Use a table for create, reopen, assign, and followers; keep complete and reminder in their existing specialized test files. + +The common assertion must be exact: + +```go +func assertStandardTaskFields(t *testing.T, data map[string]interface{}) { + t.Helper() + for _, key := range []string{"summary", "members", "start", "due", "status"} { + if _, ok := data[key]; !ok { + t.Errorf("data missing %q: %#v", key, data) + } + } +} +``` + +For `+complete`, assert `status` remains the existing derived value and that `summary`, `members`, `start`, and `due` are added. For `+reminder --remove` with no reminders, assert the early-success output also uses the Task data already fetched. + +- [ ] **Step 2: Run root-output tests and verify RED** + +Run: + +```bash +go test ./shortcuts/task -run 'Test(TaskRootOutputFields|CompleteTask|ReminderTask)' +``` + +Expected: FAIL on missing projected fields. + +- [ ] **Step 3: Project fields without changing identifiers or requests** + +For create/reopen/assign/followers/reminder, keep the existing output map and append: + +```go +projectTaskFields(outData, task, standardTaskOutputFields...) +``` + +For reminder, use the already fetched `taskObj`, including the no-existing-reminder early return. For complete, do not overwrite its derived `status`: + +```go +projectTaskFields(outData, task, taskOutputSummary, taskOutputMembers, taskOutputStart, taskOutputDue) +``` + +- [ ] **Step 4: Run root-output tests and verify GREEN** + +Run the command from Step 2. + +Expected: PASS with no additional registered HTTP stubs. + +- [ ] **Step 5: Commit root write outputs** + +```bash +git add shortcuts/task/shortcuts.go shortcuts/task/task_reopen.go shortcuts/task/task_assign.go shortcuts/task/task_followers.go shortcuts/task/task_reminder.go shortcuts/task/task_complete.go shortcuts/task/task_output_test.go shortcuts/task/task_complete_test.go shortcuts/task/task_reminder_test.go +git commit -m "feat: expand task write result fields" +``` + +### Task 4: Nested successful Task items + +**Files:** +- Modify: `shortcuts/task/task_update.go:88-110` +- Modify: `shortcuts/task/task_update_test.go:85-165` +- Modify: `shortcuts/task/tasklist_create.go:122-142` +- Modify: `shortcuts/task/tasklist_create_test.go:15-90` +- Modify: `shortcuts/task/tasklist_add_task.go:89-106` +- Modify: `shortcuts/task/tasklist_add_task_test.go:13-48` + +- [ ] **Step 1: Extend nested-item tests first** + +Populate successful Task fixtures with all five upstream fields. After decoding output, call `assertStandardTaskFields` for `tasks[]`, `created_tasks[]`, and `successful_tasks[]`. Retain assertions for `confirmed`, partial-failure exit codes, and failure item shapes. + +- [ ] **Step 2: Run nested-output tests and verify RED** + +Run: + +```bash +go test ./shortcuts/task -run 'Test(TaskUpdateNormalizesAllIDsAndReturnsConfirmedFields|CreateTasklist_PartialFailure|AddTaskToTasklist_Success)' +``` + +Expected: FAIL because successful nested items contain only identifiers and command-specific fields. + +- [ ] **Step 3: Add projection to each successful item** + +Build each existing item first, then project standard fields: + +```go +item := map[string]interface{}{ + "guid": guid, + "url": urlVal, +} +projectTaskFields(item, task, standardTaskOutputFields...) +successful = append(successful, item) +``` + +For update, retain `confirmed` in the item before projecting. Do not touch failed item construction. + +- [ ] **Step 4: Run nested-output tests and verify GREEN** + +Run the command from Step 2. + +Expected: PASS; partial-failure tests still report unchanged failed item schemas. + +- [ ] **Step 5: Commit nested result behavior** + +```bash +git add shortcuts/task/task_update.go shortcuts/task/task_update_test.go shortcuts/task/tasklist_create.go shortcuts/task/tasklist_create_test.go shortcuts/task/tasklist_add_task.go shortcuts/task/tasklist_add_task_test.go +git commit -m "feat: expose fields in nested task results" +``` + +### Task 5: Formatting, regression, and repository gates + +**Files:** +- Verify all modified Go files and module metadata. + +- [ ] **Step 1: Format modified Go files** + +Run: `gofmt -w shortcuts/task` + +Expected: command exits 0. + +- [ ] **Step 2: Run task package tests** + +Run: `go test ./shortcuts/task` + +Expected: PASS. + +- [ ] **Step 3: Run required unit-test gate** + +Run: `make unit-test` + +Expected: PASS with zero failed packages. + +- [ ] **Step 4: Run static and repository cleanliness checks** + +Run: + +```bash +go vet ./... +gofmt -l . +git diff --check +git status --short +``` + +Expected: `go vet` exits 0, `gofmt -l .` prints nothing, `git diff --check` exits 0, and status contains only intentional task changes. + +- [ ] **Step 5: Commit any formatting-only changes if present** + +```bash +git add shortcuts/task +git commit -m "chore: format task compact field changes" +``` + +Skip this commit when formatting produced no additional diff. From ef1de3a6d965f7a7066a318847f7bce298ce1dab Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 14:48:13 +0800 Subject: [PATCH 3/7] feat: add task output field projector --- shortcuts/task/task_output.go | 31 +++++++++++++++++++ shortcuts/task/task_output_test.go | 48 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 shortcuts/task/task_output.go create mode 100644 shortcuts/task/task_output_test.go diff --git a/shortcuts/task/task_output.go b/shortcuts/task/task_output.go new file mode 100644 index 0000000000..a74568d328 --- /dev/null +++ b/shortcuts/task/task_output.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +type taskOutputField string + +const ( + taskOutputSummary taskOutputField = "summary" + taskOutputMembers taskOutputField = "members" + taskOutputStart taskOutputField = "start" + taskOutputDue taskOutputField = "due" + taskOutputStatus taskOutputField = "status" +) + +var standardTaskOutputFields = []taskOutputField{ + taskOutputSummary, + taskOutputMembers, + taskOutputStart, + taskOutputDue, + taskOutputStatus, +} + +func projectTaskFields(dst, task map[string]interface{}, fields ...taskOutputField) { + for _, field := range fields { + key := string(field) + if value, ok := task[key]; ok { + dst[key] = value + } + } +} diff --git a/shortcuts/task/task_output_test.go b/shortcuts/task/task_output_test.go new file mode 100644 index 0000000000..0d41e189be --- /dev/null +++ b/shortcuts/task/task_output_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "reflect" + "testing" +) + +func TestProjectTaskFields(t *testing.T) { + task := map[string]interface{}{ + "summary": "Ship compact fields", + "members": []interface{}{map[string]interface{}{ + "id": "ou_owner", "name": "Owner", "type": "user", "role": "assignee", + }}, + "start": map[string]interface{}{"timestamp": "1000", "is_all_day": false}, + "due": map[string]interface{}{"timestamp": "2000", "is_all_day": false}, + "status": "todo", + } + out := map[string]interface{}{"guid": "task-1"} + + projectTaskFields(out, task, standardTaskOutputFields...) + + for _, field := range standardTaskOutputFields { + key := string(field) + if !reflect.DeepEqual(out[key], task[key]) { + t.Fatalf("%s = %#v, want %#v", key, out[key], task[key]) + } + } + if out["guid"] != "task-1" { + t.Fatalf("existing guid changed: %#v", out) + } +} + +func TestProjectTaskFieldsOmitsAbsentFields(t *testing.T) { + out := map[string]interface{}{} + projectTaskFields(out, map[string]interface{}{"summary": "Only title"}, standardTaskOutputFields...) + + if out["summary"] != "Only title" { + t.Fatalf("summary = %#v, want Only title", out["summary"]) + } + for _, key := range []string{"members", "start", "due", "status"} { + if _, ok := out[key]; ok { + t.Fatalf("%s unexpectedly projected: %#v", key, out) + } + } +} From 71a0599247445fdb6daf0d99502b238a9e4bfc52 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 14:49:50 +0800 Subject: [PATCH 4/7] feat: expose missing fields in task summaries --- shortcuts/task/task_get_my_tasks.go | 1 + shortcuts/task/task_get_my_tasks_test.go | 9 ++++++++ shortcuts/task/task_get_related_tasks_test.go | 4 +++- shortcuts/task/task_query_helpers.go | 2 ++ shortcuts/task/task_query_helpers_test.go | 21 +++++++++++++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/shortcuts/task/task_get_my_tasks.go b/shortcuts/task/task_get_my_tasks.go index 334430cac5..30283a3816 100644 --- a/shortcuts/task/task_get_my_tasks.go +++ b/shortcuts/task/task_get_my_tasks.go @@ -222,6 +222,7 @@ var GetMyTasks = common.Shortcut{ } } } + projectTaskFields(outputItem, item, taskOutputMembers, taskOutputStart) outputItems = append(outputItems, outputItem) } diff --git a/shortcuts/task/task_get_my_tasks_test.go b/shortcuts/task/task_get_my_tasks_test.go index 69d650439d..223dab47de 100644 --- a/shortcuts/task/task_get_my_tasks_test.go +++ b/shortcuts/task/task_get_my_tasks_test.go @@ -45,6 +45,8 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) { expectedOutput: []string{ `"due_at": "` + expectedRFC3339 + `"`, `"created_at": "` + expectedRFC3339 + `"`, + `"role": "assignee"`, + `"start": {`, }, }, { @@ -74,6 +76,13 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) { "guid": "task-123", "summary": "Test Task", "created_at": tsStr, + "members": []interface{}{ + map[string]interface{}{"id": "ou_owner", "role": "assignee", "type": "user"}, + }, + "start": map[string]interface{}{ + "timestamp": tsStr, + "is_all_day": false, + }, "due": map[string]interface{}{ "timestamp": tsStr, }, diff --git a/shortcuts/task/task_get_related_tasks_test.go b/shortcuts/task/task_get_related_tasks_test.go index 18d9de7860..2d7eb0a7a1 100644 --- a/shortcuts/task/task_get_related_tasks_test.go +++ b/shortcuts/task/task_get_related_tasks_test.go @@ -123,13 +123,15 @@ func TestGetRelatedTasks_Execute(t *testing.T) { "tasklists": []interface{}{}, "url": "https://example.com/task-123", "creator": map[string]interface{}{"id": "ou_testuser", "type": "user"}, + "start": map[string]interface{}{"timestamp": "1775088000000", "is_all_day": false}, + "due": map[string]interface{}{"timestamp": "1775174400000", "is_all_day": false}, }, }, }, }, }) }, - wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`}, + wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`, `"start":`, `"due":`}, }, { name: "pretty pagination followed by me", diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go index eba2906ef2..99e5dfd7d6 100644 --- a/shortcuts/task/task_query_helpers.go +++ b/shortcuts/task/task_query_helpers.go @@ -164,6 +164,7 @@ func outputTaskSummary(task map[string]interface{}) map[string]interface{} { } } } + projectTaskFields(out, task, taskOutputMembers, taskOutputStart, taskOutputStatus) return out } @@ -198,6 +199,7 @@ func outputRelatedTask(task map[string]interface{}) map[string]interface{} { out["completed_at"] = completed } } + projectTaskFields(out, task, taskOutputStart, taskOutputDue) return out } diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go index 09e00ae9d7..dfd2519099 100644 --- a/shortcuts/task/task_query_helpers_test.go +++ b/shortcuts/task/task_query_helpers_test.go @@ -5,6 +5,7 @@ package task import ( "errors" + "reflect" "strings" "testing" @@ -74,6 +75,11 @@ func TestOutputTaskSummary(t *testing.T) { "summary": "summary", "url": "https://example.com/task-123&suite_entity_num=t1", "created_at": "1775174400000", + "members": []interface{}{ + map[string]interface{}{"id": "ou_owner", "role": "assignee", "type": "user"}, + }, + "start": map[string]interface{}{"timestamp": "1775088000000", "is_all_day": false}, + "status": "todo", "due": map[string]interface{}{ "timestamp": "1775174400000", }, @@ -100,6 +106,15 @@ func TestOutputTaskSummary(t *testing.T) { if got["url"] == "" { t.Fatalf("expected url in output, got %#v", got) } + if tt.name == "with timestamps and due" { + if !reflect.DeepEqual(got["members"], tt.task["members"]) || + !reflect.DeepEqual(got["start"], tt.task["start"]) || got["status"] != "todo" { + t.Fatalf("search projection lost compact fields: %#v", got) + } + if _, duplicated := got["due"]; duplicated { + t.Fatalf("search projection duplicated due alongside due_at: %#v", got) + } + } }) } } @@ -200,6 +215,8 @@ func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) { "url": "https://example.com/task-123&suite_entity_num=t1", "creator": map[string]interface{}{"id": "ou_1"}, "members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}}, + "start": map[string]interface{}{"timestamp": "1775088000000", "is_all_day": false}, + "due": map[string]interface{}{"timestamp": "1775174400000", "is_all_day": false}, "created_at": "1775174400000", "completed_at": "1775174400000", }, @@ -219,6 +236,10 @@ func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) { if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] { t.Fatalf("unexpected related task output: %#v", got) } + if tt.name == "full related task" && + (!reflect.DeepEqual(got["start"], tt.task["start"]) || !reflect.DeepEqual(got["due"], tt.task["due"])) { + t.Fatalf("related task projection lost start/due: %#v", got) + } }) } From 8a79806b9bc10b97e384e117602ce124f02b1e79 Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 14:51:49 +0800 Subject: [PATCH 5/7] feat: expand task write result fields --- shortcuts/task/shortcuts.go | 1 + shortcuts/task/task_assign.go | 1 + shortcuts/task/task_complete.go | 1 + shortcuts/task/task_complete_test.go | 10 +++ shortcuts/task/task_followers.go | 1 + shortcuts/task/task_output_test.go | 96 ++++++++++++++++++++++++++++ shortcuts/task/task_reminder.go | 5 +- shortcuts/task/task_reminder_test.go | 34 ++++++++++ shortcuts/task/task_reopen.go | 1 + 9 files changed, 149 insertions(+), 1 deletion(-) diff --git a/shortcuts/task/shortcuts.go b/shortcuts/task/shortcuts.go index 4112ebd847..fa8058f208 100644 --- a/shortcuts/task/shortcuts.go +++ b/shortcuts/task/shortcuts.go @@ -247,6 +247,7 @@ var CreateTask = common.Shortcut{ "guid": guid, "url": urlVal, } + projectTaskFields(outData, task, standardTaskOutputFields...) runtime.OutFormat(outData, nil, func(w io.Writer) { fmt.Fprintf(w, "✅ Task created successfully!\n") diff --git a/shortcuts/task/task_assign.go b/shortcuts/task/task_assign.go index d0134a5189..11dc93d7f8 100644 --- a/shortcuts/task/task_assign.go +++ b/shortcuts/task/task_assign.go @@ -91,6 +91,7 @@ var AssignTask = common.Shortcut{ "guid": taskId, "url": urlVal, } + projectTaskFields(outData, task, standardTaskOutputFields...) runtime.OutFormat(outData, nil, func(w io.Writer) { fmt.Fprintf(w, "✅ Task assignes updated successfully!\n") diff --git a/shortcuts/task/task_complete.go b/shortcuts/task/task_complete.go index 9b46886572..2f49f6c2d7 100644 --- a/shortcuts/task/task_complete.go +++ b/shortcuts/task/task_complete.go @@ -101,6 +101,7 @@ var CompleteTask = common.Shortcut{ "completed_at": completedAt, "already_completed": alreadyCompleted, } + projectTaskFields(outData, task, taskOutputSummary, taskOutputMembers, taskOutputStart, taskOutputDue) runtime.OutFormat(outData, nil, func(w io.Writer) { summary, _ := task["summary"].(string) diff --git a/shortcuts/task/task_complete_test.go b/shortcuts/task/task_complete_test.go index 7394324bcb..c4cb498a32 100644 --- a/shortcuts/task/task_complete_test.go +++ b/shortcuts/task/task_complete_test.go @@ -51,6 +51,10 @@ func TestCompleteTask(t *testing.T) { `"status": "done"`, `"completed_at": "1775174400000"`, `"already_completed": false`, + `"summary": "Test Task task-789"`, + `"members":`, + `"start":`, + `"due":`, }, }, } @@ -76,6 +80,9 @@ func TestCompleteTask(t *testing.T) { "summary": "Test Task " + tt.taskId, "completed_at": completedAt, "url": "https://example.com/" + tt.taskId, + "members": fullTaskOutputFixture()["members"], + "start": fullTaskOutputFixture()["start"], + "due": fullTaskOutputFixture()["due"], }, }, }, @@ -93,6 +100,9 @@ func TestCompleteTask(t *testing.T) { "summary": "Test Task " + tt.taskId, "completed_at": "1775174400000", "url": "https://example.com/" + tt.taskId, + "members": fullTaskOutputFixture()["members"], + "start": fullTaskOutputFixture()["start"], + "due": fullTaskOutputFixture()["due"], }, }, }, diff --git a/shortcuts/task/task_followers.go b/shortcuts/task/task_followers.go index 3016ad1106..9ea4853363 100644 --- a/shortcuts/task/task_followers.go +++ b/shortcuts/task/task_followers.go @@ -92,6 +92,7 @@ var FollowersTask = common.Shortcut{ "guid": taskId, "url": urlVal, } + projectTaskFields(outData, task, standardTaskOutputFields...) runtime.OutFormat(outData, nil, func(w io.Writer) { fmt.Fprintf(w, "✅ Task followers updated successfully!\n") diff --git a/shortcuts/task/task_output_test.go b/shortcuts/task/task_output_test.go index 0d41e189be..6a2d9ffef4 100644 --- a/shortcuts/task/task_output_test.go +++ b/shortcuts/task/task_output_test.go @@ -4,8 +4,13 @@ package task import ( + "encoding/json" + "net/http" "reflect" "testing" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" ) func TestProjectTaskFields(t *testing.T) { @@ -46,3 +51,94 @@ func TestProjectTaskFieldsOmitsAbsentFields(t *testing.T) { } } } + +func TestTaskRootOutputFields(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + method string + url string + args []string + }{ + { + name: "create", shortcut: CreateTask, method: http.MethodPost, + url: "/open-apis/task/v2/tasks", + args: []string{"+create", "--summary", "Requested title", "--as", "bot", "--format", "json"}, + }, + { + name: "reopen", shortcut: ReopenTask, method: http.MethodPatch, + url: "/open-apis/task/v2/tasks/task-1", + args: []string{"+reopen", "--task-id", "task-1", "--as", "bot", "--format", "json"}, + }, + { + name: "assign", shortcut: AssignTask, method: http.MethodPost, + url: "/open-apis/task/v2/tasks/task-1/add_members", + args: []string{"+assign", "--task-id", "task-1", "--add", "ou_owner", "--as", "bot", "--format", "json"}, + }, + { + name: "followers", shortcut: FollowersTask, method: http.MethodPost, + url: "/open-apis/task/v2/tasks/task-1/add_members", + args: []string{"+followers", "--task-id", "task-1", "--add", "ou_follower", "--as", "bot", "--format", "json"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + reg.Register(&httpmock.Stub{ + Method: tt.method, + URL: tt.url, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"task": fullTaskOutputFixture()}, + }, + }) + + shortcut := tt.shortcut + shortcut.AuthTypes = []string{"bot", "user"} + if err := runMountedTaskShortcut(t, shortcut, tt.args, f, stdout); err != nil { + t.Fatalf("runMountedTaskShortcut() error = %v", err) + } + + assertStandardTaskFields(t, decodeTaskOutputData(t, stdout.Bytes())) + }) + } +} + +func fullTaskOutputFixture() map[string]interface{} { + return map[string]interface{}{ + "guid": "task-1", + "url": "https://example.com/task-1&suite_entity_num=t1", + "summary": "Server title", + "members": []interface{}{ + map[string]interface{}{"id": "ou_owner", "name": "Owner", "type": "user", "role": "assignee"}, + }, + "start": map[string]interface{}{"timestamp": "1000", "is_all_day": false}, + "due": map[string]interface{}{"timestamp": "2000", "is_all_day": false}, + "status": "todo", + } +} + +func decodeTaskOutputData(t *testing.T, raw []byte) map[string]interface{} { + t.Helper() + var envelope map[string]interface{} + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, raw) + } + data, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data = %#v, want object", envelope["data"]) + } + return data +} + +func assertStandardTaskFields(t *testing.T, data map[string]interface{}) { + t.Helper() + for _, key := range []string{"summary", "members", "start", "due", "status"} { + if _, ok := data[key]; !ok { + t.Errorf("data missing %q: %#v", key, data) + } + } +} diff --git a/shortcuts/task/task_reminder.go b/shortcuts/task/task_reminder.go index 029ab21c6d..6286d1ecda 100644 --- a/shortcuts/task/task_reminder.go +++ b/shortcuts/task/task_reminder.go @@ -77,7 +77,9 @@ var ReminderTask = common.Shortcut{ if runtime.Bool("remove") { if len(reminders) == 0 { - runtime.OutFormat(map[string]interface{}{"guid": taskId}, nil, func(w io.Writer) { + outData := map[string]interface{}{"guid": taskId} + projectTaskFields(outData, taskObj, standardTaskOutputFields...) + runtime.OutFormat(outData, nil, func(w io.Writer) { fmt.Fprintln(w, "No existing reminders to remove.") }) return nil @@ -169,6 +171,7 @@ var ReminderTask = common.Shortcut{ "guid": taskId, "url": urlVal, } + projectTaskFields(outData, taskObj, standardTaskOutputFields...) runtime.OutFormat(outData, nil, func(w io.Writer) { fmt.Fprintf(w, "✅ Task reminders updated successfully!\n") diff --git a/shortcuts/task/task_reminder_test.go b/shortcuts/task/task_reminder_test.go index 0d8319c063..1459d14191 100644 --- a/shortcuts/task/task_reminder_test.go +++ b/shortcuts/task/task_reminder_test.go @@ -4,10 +4,12 @@ package task import ( + "encoding/json" "errors" "testing" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" ) @@ -53,3 +55,35 @@ func TestReminderTask_CannotSpecifyBoth(t *testing.T) { t.Errorf("exit code = %d, want %d", got, output.ExitValidation) } } + +func TestReminderTask_NoExistingReminderReturnsTaskFields(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + task := fullTaskOutputFixture() + task["reminders"] = []interface{}{} + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/task-1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"task": task}, + }, + }) + + shortcut := ReminderTask + shortcut.AuthTypes = []string{"bot", "user"} + if err := runMountedTaskShortcut(t, shortcut, []string{ + "+reminder", "--task-id", "task-1", "--remove", "--as", "bot", "--format", "json", + }, f, stdout); err != nil { + t.Fatalf("ReminderTask error = %v", err) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + assertStandardTaskFields(t, data) +} diff --git a/shortcuts/task/task_reopen.go b/shortcuts/task/task_reopen.go index 0790ee0d8e..bf7bde6c28 100644 --- a/shortcuts/task/task_reopen.go +++ b/shortcuts/task/task_reopen.go @@ -55,6 +55,7 @@ var ReopenTask = common.Shortcut{ "guid": guid, "url": urlVal, } + projectTaskFields(outData, task, standardTaskOutputFields...) runtime.OutFormat(outData, nil, func(w io.Writer) { summary, _ := task["summary"].(string) From 7d065c0e86f4453a72ba87ab4c5e913f7d4d908e Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 14:53:33 +0800 Subject: [PATCH 6/7] feat: expose fields in nested task results --- shortcuts/task/task_update.go | 6 +++-- shortcuts/task/task_update_test.go | 28 +++++++++++------------- shortcuts/task/tasklist_add_task.go | 6 +++-- shortcuts/task/tasklist_add_task_test.go | 18 ++++++++++----- shortcuts/task/tasklist_create.go | 6 +++-- shortcuts/task/tasklist_create_test.go | 21 +++++++++++++----- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/shortcuts/task/task_update.go b/shortcuts/task/task_update.go index 00cf7f8c48..fed2c1cdbc 100644 --- a/shortcuts/task/task_update.go +++ b/shortcuts/task/task_update.go @@ -97,11 +97,13 @@ var UpdateTask = common.Shortcut{ confirmed[field] = value } } - tasks = append(tasks, map[string]interface{}{ + item := map[string]interface{}{ "guid": guid, "url": urlVal, "confirmed": confirmed, - }) + } + projectTaskFields(item, task, standardTaskOutputFields...) + tasks = append(tasks, item) } // Standardized write output: return resource identifiers outData := map[string]interface{}{ diff --git a/shortcuts/task/task_update_test.go b/shortcuts/task/task_update_test.go index 396477db7d..ea29e617f5 100644 --- a/shortcuts/task/task_update_test.go +++ b/shortcuts/task/task_update_test.go @@ -84,20 +84,22 @@ func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) { func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) { f, stdout, _, reg := taskShortcutTestFactory(t) warmTenantToken(t, f, reg) + firstTaskResponse := fullTaskOutputFixture() + firstTaskResponse["guid"] = "task-guid-1" + firstTaskResponse["url"] = "https://example.com/task-guid-1" + firstTaskResponse["summary"] = "server summary one" + firstTaskResponse["description"] = "server description one" + secondTaskResponse := fullTaskOutputFixture() + secondTaskResponse["guid"] = "task-guid-2" + secondTaskResponse["url"] = "https://example.com/task-guid-2" + secondTaskResponse["summary"] = "server summary two" first := &httpmock.Stub{ Method: "PATCH", URL: "/open-apis/task/v2/tasks/task-guid-1", Body: map[string]interface{}{ "code": 0, "msg": "success", - "data": map[string]interface{}{ - "task": map[string]interface{}{ - "guid": "task-guid-1", - "url": "https://example.com/task-guid-1", - "summary": "server summary one", - "description": "server description one", - }, - }, + "data": map[string]interface{}{"task": firstTaskResponse}, }, } second := &httpmock.Stub{ @@ -105,13 +107,7 @@ func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) { URL: "/open-apis/task/v2/tasks/task-guid-2", Body: map[string]interface{}{ "code": 0, "msg": "success", - "data": map[string]interface{}{ - "task": map[string]interface{}{ - "guid": "task-guid-2", - "url": "https://example.com/task-guid-2", - "summary": "server summary two", - }, - }, + "data": map[string]interface{}{"task": secondTaskResponse}, }, } reg.Register(first) @@ -155,6 +151,7 @@ func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) { }) { t.Fatalf("first confirmed = %#v", got) } + assertStandardTaskFields(t, firstTask) secondTask := tasks[1].(map[string]interface{}) if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{ @@ -162,6 +159,7 @@ func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) { }) { t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got) } + assertStandardTaskFields(t, secondTask) } func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) { diff --git a/shortcuts/task/tasklist_add_task.go b/shortcuts/task/tasklist_add_task.go index 6f868b7ed3..65410595b8 100644 --- a/shortcuts/task/tasklist_add_task.go +++ b/shortcuts/task/tasklist_add_task.go @@ -91,10 +91,12 @@ var AddTaskToTasklist = common.Shortcut{ guid, _ := task["guid"].(string) taskUrl, _ := task["url"].(string) taskUrl = truncateTaskURL(taskUrl) - successful = append(successful, map[string]interface{}{ + item := map[string]interface{}{ "guid": guid, "url": taskUrl, - }) + } + projectTaskFields(item, task, standardTaskOutputFields...) + successful = append(successful, item) } } diff --git a/shortcuts/task/tasklist_add_task_test.go b/shortcuts/task/tasklist_add_task_test.go index 5a19c6d23f..832457bb57 100644 --- a/shortcuts/task/tasklist_add_task_test.go +++ b/shortcuts/task/tasklist_add_task_test.go @@ -4,6 +4,7 @@ package task import ( + "encoding/json" "errors" "strings" "testing" @@ -16,17 +17,14 @@ import ( func TestAddTaskToTasklist_Success(t *testing.T) { f, stdout, _, reg := taskShortcutTestFactory(t) warmTenantToken(t, f, reg) + task := fullTaskOutputFixture() reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/task/v2/tasks/task-1/add_tasklist", Body: map[string]interface{}{ "code": 0, "msg": "success", - "data": map[string]interface{}{ - "task": map[string]interface{}{ - "guid": "task-1", - }, - }, + "data": map[string]interface{}{"task": task}, }, }) @@ -43,6 +41,16 @@ func TestAddTaskToTasklist_Success(t *testing.T) { if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) { t.Errorf("expected tasklist_guid in output, got: %s", out) } + var envelope map[string]interface{} + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode output: %v\n%s", decodeErr, out) + } + data, _ := envelope["data"].(map[string]interface{}) + successful, _ := data["successful_tasks"].([]interface{}) + if len(successful) != 1 { + t.Fatalf("successful_tasks = %#v, want one item", data["successful_tasks"]) + } + assertStandardTaskFields(t, successful[0].(map[string]interface{})) } // TestAddTaskToTasklist_PartialFailure exercises the batch path: some tasks diff --git a/shortcuts/task/tasklist_create.go b/shortcuts/task/tasklist_create.go index 0923bba492..471a11a1a0 100644 --- a/shortcuts/task/tasklist_create.go +++ b/shortcuts/task/tasklist_create.go @@ -123,10 +123,12 @@ var CreateTasklist = common.Shortcut{ guid, _ := t["guid"].(string) urlVal, _ := t["url"].(string) urlVal = truncateTaskURL(urlVal) - createdTasks = append(createdTasks, map[string]interface{}{ + item := map[string]interface{}{ "guid": guid, "url": urlVal, - }) + } + projectTaskFields(item, t, standardTaskOutputFields...) + createdTasks = append(createdTasks, item) } }(i, taskDef) } diff --git a/shortcuts/task/tasklist_create_test.go b/shortcuts/task/tasklist_create_test.go index 16abe4b7cd..09c9fe1a69 100644 --- a/shortcuts/task/tasklist_create_test.go +++ b/shortcuts/task/tasklist_create_test.go @@ -5,6 +5,7 @@ package task import ( "bytes" + "encoding/json" "errors" "strings" "testing" @@ -25,6 +26,9 @@ import ( func TestCreateTasklist_PartialFailure(t *testing.T) { f, stdout, _, reg := taskShortcutTestFactory(t) warmTenantToken(t, f, reg) + createdTask := fullTaskOutputFixture() + createdTask["guid"] = "task-ok" + createdTask["url"] = "https://example.feishu.cn/task-ok" reg.Register(&httpmock.Stub{ Method: "POST", @@ -48,12 +52,7 @@ func TestCreateTasklist_PartialFailure(t *testing.T) { BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) }, Body: map[string]interface{}{ "code": 0, "msg": "success", - "data": map[string]interface{}{ - "task": map[string]interface{}{ - "guid": "task-ok", - "url": "https://example.feishu.cn/task-ok", - }, - }, + "data": map[string]interface{}{"task": createdTask}, }, }) @@ -105,6 +104,16 @@ func TestCreateTasklist_PartialFailure(t *testing.T) { if strings.Contains(out, "permission_error") { t.Errorf("legacy type \"permission_error\" leaked into output: %s", out) } + var envelope map[string]interface{} + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode output: %v\n%s", decodeErr, out) + } + resultData, _ := envelope["data"].(map[string]interface{}) + createdTasks, _ := resultData["created_tasks"].([]interface{}) + if len(createdTasks) != 1 { + t.Fatalf("created_tasks = %#v, want one item", resultData["created_tasks"]) + } + assertStandardTaskFields(t, createdTasks[0].(map[string]interface{})) } func TestCreateTasklist_PartialFailurePrettyOutput(t *testing.T) { From 7c7ad7d7389533a69bb3624820f9f4a185b257db Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 30 Jul 2026 16:04:36 +0800 Subject: [PATCH 7/7] chore: remove internal planning artifacts --- .../plans/2026-07-30-task-compact-fields.md | 350 ------------------ .../2026-07-30-task-compact-fields-design.md | 111 ------ 2 files changed, 461 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-30-task-compact-fields.md delete mode 100644 docs/superpowers/specs/2026-07-30-task-compact-fields-design.md diff --git a/docs/superpowers/plans/2026-07-30-task-compact-fields.md b/docs/superpowers/plans/2026-07-30-task-compact-fields.md deleted file mode 100644 index 279a0a47c6..0000000000 --- a/docs/superpowers/plans/2026-07-30-task-compact-fields.md +++ /dev/null @@ -1,350 +0,0 @@ -# Task Compact Fields Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Preserve existing task shortcut output contracts while projecting task title, members, start, due, and completion state from Task entities the commands already receive. - -**Architecture:** Add one typed-field projection helper at the task output boundary, then call it from existing compact-output builders. Each command selects only missing semantic fields, so existing aliases such as `due_at`, `completed`, and `completed_at` remain unchanged and no redundant fields or new API calls are introduced. - -**Tech Stack:** Go, Cobra shortcut runtime, `internal/httpmock`, standard `testing`, `make unit-test` - ---- - -## File Map - -- Create `shortcuts/task/task_output.go`: typed task field identifiers and faithful map-to-map projection helper. -- Create `shortcuts/task/task_output_test.go`: helper contract plus root write-output command contracts. -- Modify `shortcuts/task/task_query_helpers.go`: add missing fields to search and related-task projections. -- Modify `shortcuts/task/task_query_helpers_test.go`: pin search/related projection fields and absent-field behavior. -- Modify `shortcuts/task/task_get_my_tasks.go`: add `members` and `start` to my-task items. -- Modify `shortcuts/task/task_get_my_tasks_test.go`: pin final JSON paths. -- Modify `shortcuts/task/task_get_related_tasks_test.go`: pin `start` and `due` in command output. -- Modify `shortcuts/task/shortcuts.go`, `task_reopen.go`, `task_assign.go`, `task_followers.go`, `task_reminder.go`, and `task_complete.go`: project fields already present in each Task response. -- Modify the corresponding command tests to pin root output fields. -- Modify `shortcuts/task/task_update.go`, `tasklist_create.go`, and `tasklist_add_task.go`: project fields into successful nested Task items. -- Modify their tests to pin nested output paths while preserving failure schemas. - -### Task 1: Shared faithful projection helper - -**Files:** -- Create: `shortcuts/task/task_output.go` -- Create: `shortcuts/task/task_output_test.go` - -- [ ] **Step 1: Write the failing helper tests** - -Add tests that call the not-yet-defined helper with a full source entity and assert exact preservation, then call it with absent fields and assert those keys are omitted: - -```go -func TestProjectTaskFields(t *testing.T) { - task := map[string]interface{}{ - "summary": "Ship compact fields", - "members": []interface{}{map[string]interface{}{ - "id": "ou_owner", "name": "Owner", "type": "user", "role": "assignee", - }}, - "start": map[string]interface{}{"timestamp": "1000", "is_all_day": false}, - "due": map[string]interface{}{"timestamp": "2000", "is_all_day": false}, - "status": "todo", - } - out := map[string]interface{}{"guid": "task-1"} - - projectTaskFields(out, task, standardTaskOutputFields...) - - for _, field := range standardTaskOutputFields { - key := string(field) - if !reflect.DeepEqual(out[key], task[key]) { - t.Fatalf("%s = %#v, want %#v", key, out[key], task[key]) - } - } -} - -func TestProjectTaskFieldsOmitsAbsentFields(t *testing.T) { - out := map[string]interface{}{} - projectTaskFields(out, map[string]interface{}{"summary": "Only title"}, standardTaskOutputFields...) - if _, ok := out["members"]; ok { - t.Fatalf("members unexpectedly projected: %#v", out) - } -} -``` - -- [ ] **Step 2: Run the tests and verify RED** - -Run: `go test ./shortcuts/task -run '^TestProjectTaskFields'` - -Expected: build failure because `projectTaskFields` and `standardTaskOutputFields` do not exist. - -- [ ] **Step 3: Add the minimal typed-field helper** - -```go -package task - -type taskOutputField string - -const ( - taskOutputSummary taskOutputField = "summary" - taskOutputMembers taskOutputField = "members" - taskOutputStart taskOutputField = "start" - taskOutputDue taskOutputField = "due" - taskOutputStatus taskOutputField = "status" -) - -var standardTaskOutputFields = []taskOutputField{ - taskOutputSummary, - taskOutputMembers, - taskOutputStart, - taskOutputDue, - taskOutputStatus, -} - -func projectTaskFields(dst, task map[string]interface{}, fields ...taskOutputField) { - for _, field := range fields { - key := string(field) - if value, ok := task[key]; ok { - dst[key] = value - } - } -} -``` - -- [ ] **Step 4: Run helper tests and verify GREEN** - -Run: `go test ./shortcuts/task -run '^TestProjectTaskFields'` - -Expected: PASS. - -- [ ] **Step 5: Commit the helper** - -```bash -git add shortcuts/task/task_output.go shortcuts/task/task_output_test.go -git commit -m "feat: add task output field projector" -``` - -### Task 2: Read-summary outputs - -**Files:** -- Modify: `shortcuts/task/task_query_helpers.go:136-201` -- Modify: `shortcuts/task/task_query_helpers_test.go:68-225` -- Modify: `shortcuts/task/task_get_my_tasks.go:199-225` -- Modify: `shortcuts/task/task_get_my_tasks_test.go:13-101` -- Modify: `shortcuts/task/task_get_related_tasks_test.go:95-185` - -- [ ] **Step 1: Extend read-output tests first** - -Add `members`, `start`, `due`, and `status` fixtures to the direct helper tests. Assert: - -```go -projected := outputTaskSummary(task) -if !reflect.DeepEqual(projected["members"], task["members"]) || - !reflect.DeepEqual(projected["start"], task["start"]) || - projected["status"] != "todo" { - t.Fatalf("search projection lost compact fields: %#v", projected) -} -if _, duplicated := projected["due"]; duplicated { - t.Fatalf("search projection duplicated due alongside due_at: %#v", projected) -} -``` - -For related tasks, assert `start` and `due` are present. In the JSON execution fixtures for `+get-my-tasks`, add `members` and `start` and assert their serialized paths while retaining `due_at` and `completed`. In `+get-related-tasks`, add `start` and `due` to the fixture and expected JSON fragments. - -- [ ] **Step 2: Run focused read tests and verify RED** - -Run: - -```bash -go test ./shortcuts/task -run 'Test(OutputTaskSummary|OutputRelatedTaskAndTimeRangeFilter|GetMyTasks_LocalTimeFormatting|GetRelatedTasks_Execute)' -``` - -Expected: FAIL because the new fields are absent. - -- [ ] **Step 3: Add only the missing projections** - -After each existing output map is built: - -```go -// outputTaskSummary: due_at/completed_at already carry those meanings. -projectTaskFields(out, task, taskOutputMembers, taskOutputStart, taskOutputStatus) - -// outputRelatedTask: members/status already exist. -projectTaskFields(out, task, taskOutputStart, taskOutputDue) - -// GetMyTasks outputItem: due_at/completed already exist. -projectTaskFields(outputItem, item, taskOutputMembers, taskOutputStart) -``` - -- [ ] **Step 4: Run focused tests and verify GREEN** - -Run the command from Step 2. - -Expected: PASS. - -- [ ] **Step 5: Commit read-summary behavior** - -```bash -git add shortcuts/task/task_query_helpers.go shortcuts/task/task_query_helpers_test.go shortcuts/task/task_get_my_tasks.go shortcuts/task/task_get_my_tasks_test.go shortcuts/task/task_get_related_tasks_test.go -git commit -m "feat: expose missing fields in task summaries" -``` - -### Task 3: Root write-result outputs - -**Files:** -- Modify: `shortcuts/task/shortcuts.go:234-250` -- Modify: `shortcuts/task/task_reopen.go:42-59` -- Modify: `shortcuts/task/task_assign.go:84-95` -- Modify: `shortcuts/task/task_followers.go:85-96` -- Modify: `shortcuts/task/task_reminder.go:75-173` -- Modify: `shortcuts/task/task_complete.go:86-103` -- Test: `shortcuts/task/task_output_test.go` -- Test: `shortcuts/task/task_complete_test.go` -- Test: `shortcuts/task/task_reminder_test.go` - -- [ ] **Step 1: Write root output contract tests** - -Use `taskShortcutTestFactory`, `warmTenantToken`, and `httpmock.Stub` to run each shortcut with a Task response containing the five upstream fields. Decode the JSON envelope and assert existing `guid`/`url` plus the new fields. Use a table for create, reopen, assign, and followers; keep complete and reminder in their existing specialized test files. - -The common assertion must be exact: - -```go -func assertStandardTaskFields(t *testing.T, data map[string]interface{}) { - t.Helper() - for _, key := range []string{"summary", "members", "start", "due", "status"} { - if _, ok := data[key]; !ok { - t.Errorf("data missing %q: %#v", key, data) - } - } -} -``` - -For `+complete`, assert `status` remains the existing derived value and that `summary`, `members`, `start`, and `due` are added. For `+reminder --remove` with no reminders, assert the early-success output also uses the Task data already fetched. - -- [ ] **Step 2: Run root-output tests and verify RED** - -Run: - -```bash -go test ./shortcuts/task -run 'Test(TaskRootOutputFields|CompleteTask|ReminderTask)' -``` - -Expected: FAIL on missing projected fields. - -- [ ] **Step 3: Project fields without changing identifiers or requests** - -For create/reopen/assign/followers/reminder, keep the existing output map and append: - -```go -projectTaskFields(outData, task, standardTaskOutputFields...) -``` - -For reminder, use the already fetched `taskObj`, including the no-existing-reminder early return. For complete, do not overwrite its derived `status`: - -```go -projectTaskFields(outData, task, taskOutputSummary, taskOutputMembers, taskOutputStart, taskOutputDue) -``` - -- [ ] **Step 4: Run root-output tests and verify GREEN** - -Run the command from Step 2. - -Expected: PASS with no additional registered HTTP stubs. - -- [ ] **Step 5: Commit root write outputs** - -```bash -git add shortcuts/task/shortcuts.go shortcuts/task/task_reopen.go shortcuts/task/task_assign.go shortcuts/task/task_followers.go shortcuts/task/task_reminder.go shortcuts/task/task_complete.go shortcuts/task/task_output_test.go shortcuts/task/task_complete_test.go shortcuts/task/task_reminder_test.go -git commit -m "feat: expand task write result fields" -``` - -### Task 4: Nested successful Task items - -**Files:** -- Modify: `shortcuts/task/task_update.go:88-110` -- Modify: `shortcuts/task/task_update_test.go:85-165` -- Modify: `shortcuts/task/tasklist_create.go:122-142` -- Modify: `shortcuts/task/tasklist_create_test.go:15-90` -- Modify: `shortcuts/task/tasklist_add_task.go:89-106` -- Modify: `shortcuts/task/tasklist_add_task_test.go:13-48` - -- [ ] **Step 1: Extend nested-item tests first** - -Populate successful Task fixtures with all five upstream fields. After decoding output, call `assertStandardTaskFields` for `tasks[]`, `created_tasks[]`, and `successful_tasks[]`. Retain assertions for `confirmed`, partial-failure exit codes, and failure item shapes. - -- [ ] **Step 2: Run nested-output tests and verify RED** - -Run: - -```bash -go test ./shortcuts/task -run 'Test(TaskUpdateNormalizesAllIDsAndReturnsConfirmedFields|CreateTasklist_PartialFailure|AddTaskToTasklist_Success)' -``` - -Expected: FAIL because successful nested items contain only identifiers and command-specific fields. - -- [ ] **Step 3: Add projection to each successful item** - -Build each existing item first, then project standard fields: - -```go -item := map[string]interface{}{ - "guid": guid, - "url": urlVal, -} -projectTaskFields(item, task, standardTaskOutputFields...) -successful = append(successful, item) -``` - -For update, retain `confirmed` in the item before projecting. Do not touch failed item construction. - -- [ ] **Step 4: Run nested-output tests and verify GREEN** - -Run the command from Step 2. - -Expected: PASS; partial-failure tests still report unchanged failed item schemas. - -- [ ] **Step 5: Commit nested result behavior** - -```bash -git add shortcuts/task/task_update.go shortcuts/task/task_update_test.go shortcuts/task/tasklist_create.go shortcuts/task/tasklist_create_test.go shortcuts/task/tasklist_add_task.go shortcuts/task/tasklist_add_task_test.go -git commit -m "feat: expose fields in nested task results" -``` - -### Task 5: Formatting, regression, and repository gates - -**Files:** -- Verify all modified Go files and module metadata. - -- [ ] **Step 1: Format modified Go files** - -Run: `gofmt -w shortcuts/task` - -Expected: command exits 0. - -- [ ] **Step 2: Run task package tests** - -Run: `go test ./shortcuts/task` - -Expected: PASS. - -- [ ] **Step 3: Run required unit-test gate** - -Run: `make unit-test` - -Expected: PASS with zero failed packages. - -- [ ] **Step 4: Run static and repository cleanliness checks** - -Run: - -```bash -go vet ./... -gofmt -l . -git diff --check -git status --short -``` - -Expected: `go vet` exits 0, `gofmt -l .` prints nothing, `git diff --check` exits 0, and status contains only intentional task changes. - -- [ ] **Step 5: Commit any formatting-only changes if present** - -```bash -git add shortcuts/task -git commit -m "chore: format task compact field changes" -``` - -Skip this commit when formatting produced no additional diff. diff --git a/docs/superpowers/specs/2026-07-30-task-compact-fields-design.md b/docs/superpowers/specs/2026-07-30-task-compact-fields-design.md deleted file mode 100644 index 29c07755d1..0000000000 --- a/docs/superpowers/specs/2026-07-30-task-compact-fields-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Task Compact Fields Design - -## Goal - -Expose the task title, members, start time, due time, and completion state when -task shortcuts already receive a Task entity but currently discard those -fields while building compact output. - -The change must remain backward compatible: existing JSON fields, paths, and -types stay unchanged, and the implementation must not add API requests. - -## Compatibility Rules - -- Do not remove, rename, or change the type of an existing output field. -- Add a field only when the command does not already expose the same meaning. -- Reuse Task API names and values for newly projected entity fields: - `summary`, `members`, `start`, `due`, and `status`. -- Preserve existing compact aliases such as `due_at`, `completed`, and - `completed_at`; do not emit a second field for the same meaning in that - command. -- Preserve current fallback behavior. In particular, a `+search` item whose - detail request fails continues to contain only `guid` and `url`. -- Empty API values are transcribed faithfully. No synthetic member, time, or - status value is invented. - -## Affected Outputs - -### Resource-reference write results - -When their existing API response contains `data.task`, extend the current -`guid`/`url` projection with `summary`, `members`, `start`, `due`, and `status`: - -- `task +create` -- `task +reopen` -- `task +assign` -- `task +followers` -- `task +reminder` - -`task +complete` already returns `status`; add the missing `summary`, `members`, -`start`, and `due` fields without changing `completed_at` or -`already_completed`. - -### Nested write results - -Extend each successful Task item using the same projection: - -- `task +update`: `tasks[]` -- `task +tasklist-create`: `created_tasks[]` -- `task +tasklist-task-add`: `successful_tasks[]` - -Batch failure item schemas remain unchanged. - -### Read summaries - -Add only missing meanings to avoid redundant fields: - -| Command | Existing equivalents kept | Newly projected fields | -| --- | --- | --- | -| `task +search` | `summary`, `due_at`, `completed_at` | `members`, `start`, `status` | -| `task +get-my-tasks` | `summary`, `due_at`, `completed` | `members`, `start` | -| `task +get-related-tasks` | `summary`, `members`, `status` | `start`, `due` | - -The existing command-specific formatted time fields remain unchanged. - -## Excluded Outputs - -- Generated raw API commands already return the full upstream entity. -- `task +set-ancestor` is excluded because its endpoint does not provide a - usable Task entity; fetching one would introduce an extra request and new - post-mutation failure semantics. -- Comment, attachment, and tasklist outputs represent different entity types. -- Tasklist-level fields in `task +tasklist-create` remain unchanged; only its - `created_tasks[]` items are Task entities. - -## Data Flow - -At the API boundary, existing shortcut code receives loose Task maps. A shared -projection helper copies the requested upstream fields into the existing -compact result map. Member data is preserved as returned, including `role`, so -callers can identify responsible users with `role == "assignee"` without losing -followers. - -Every affected command uses Task data it already has: - -- create/update/complete/reopen/member/tasklist mutations use their existing - Task response; -- reminder uses its existing pre-mutation Task GET because reminder mutations - do not change the projected fields; -- list-related and my-tasks use the returned list items; -- search keeps its existing per-hit detail query. - -No new GET or other API call is introduced. - -## Error and Fallback Behavior - -Projection is best-effort and cannot turn a successful API operation into an -error. Missing upstream fields are omitted rather than guessed. Existing typed -API errors, partial-failure envelopes, exit codes, and per-item failure objects -remain unchanged. - -## Testing - -- Add focused helper tests that pin faithful projection and omission of absent - fields. -- Extend command tests to assert newly exposed fields at the final JSON paths. -- Cover root write results, nested batch results, and all three read-summary - variants. -- Keep existing fallback tests green, especially search detail failure and - partial batch failures. -- Run task package tests, `gofmt`, and the repository-required unit test gate - before completion.