Query tasks and notes by tag (#5) #12

Merged
ethan merged 4 commits from feat/query-tasks-by-tag into feat/write-side-gaps 2026-08-09 20:38:54 -05:00
Member

Closes #5.

Stacked on #11 (which is stacked on #10). Merge in order and each retargets cleanly.

Filtering is client-side over the full state already fetched — no new endpoint. Matching is on the lowercased display name, which is also exactly what appears in an item's tags array.

The raw_name hazard is real, and now has a test

The issue is right to be emphatic. Live from this account:

name raw_name
p1 top_3
p7 weekly_priorities
project_status status

A live test asserts that every tag whose raw_name has drifted from its name fails to resolve by raw_name — so this can't regress quietly as tags get rebuilt.

Two findings that changed the design

1. list_notes needs the same filter — otherwise the sweep misses everything

list_tasks excludes notes. In this estate the status tags are held exclusively by notes:

steady_🟢         tasks=  0  notes=  9
at-risk_⚠️       tasks=  0  notes=  5
project_status   tasks=  0  notes=  0   notes+children= 14

A strip-then-apply rotation sweeping only list_tasks would report zero carriers and silently leave all 14 tagged — which is precisely the accumulation the strip step exists to prevent. So list_notes takes the identical tag / include_child_tags params.

Consequence for the caller: clearing a tag from everything means sweeping both methods, with include_completed=true on both. Called out in TOOLS.md.

2. The tag registry lags tagging

A tag applied to an item appears in that item's tags array immediately but only reaches the account tag list ~6 seconds later (measured). My first cut validated against the registry alone and the live test caught it — a tag that demonstrably existed on a task was rejected as unknown.

An unknown tag still raises rather than returning [] (so a typo never masquerades as "nothing carries it"), but the known set is now the registry ∪ the names in use on the listed items. A genuine typo matches neither.

Nested tags are opt-in

Confirmed live: an item tagged with a child does not carry the parent — all 9 steady_🟢 carriers lack project_status. So an exact parent query returns only items tagged with the parent itself; include_child_tags=true walks parent links downward, transitively.

This answers the issue's open question directly, and gives the rotation the "find every carrier" primitive it needs: project_status + children finds all 14.

Answering the acceptance list

  • Match on display name ✅ — with a regression test against real drifted tags
  • Nested tag behaviour documented ✅ — opt-in, transitive, DESIGN.md §13
  • Completed tasks documented ✅ — excluded by default, include_completed=true reaches them; the strip step needs it or a tag survives on a closed item

Testing

178 passed, plus 7 live tests. Contract in DESIGN.md §13.

🤖 Generated with Claude Code

https://claude.ai/code/session_012VU4wBhFAAosZLQcWqvHsf

Closes #5. > **Stacked on #11** (which is stacked on #10). Merge in order and each retargets cleanly. Filtering is client-side over the full state already fetched — no new endpoint. Matching is on the lowercased display `name`, which is also exactly what appears in an item's `tags` array. ## The `raw_name` hazard is real, and now has a test The issue is right to be emphatic. Live from this account: | `name` | `raw_name` | |---|---| | `p1` | `top_3` | | `p7` | `weekly_priorities` | | `project_status` | `status` | A live test asserts that **every** tag whose `raw_name` has drifted from its `name` fails to resolve by `raw_name` — so this can't regress quietly as tags get rebuilt. ## Two findings that changed the design ### 1. `list_notes` needs the same filter — otherwise the sweep misses everything `list_tasks` excludes notes. In this estate the status tags are held **exclusively by notes**: ``` steady_🟢 tasks= 0 notes= 9 at-risk_⚠️ tasks= 0 notes= 5 project_status tasks= 0 notes= 0 notes+children= 14 ``` A strip-then-apply rotation sweeping only `list_tasks` would report zero carriers and silently leave all 14 tagged — which is precisely the accumulation the strip step exists to prevent. So `list_notes` takes the identical `tag` / `include_child_tags` params. **Consequence for the caller:** clearing a tag from everything means sweeping *both* methods, with `include_completed=true` on both. Called out in `TOOLS.md`. ### 2. The tag registry lags tagging A tag applied to an item appears in that item's `tags` array immediately but only reaches the account tag list **~6 seconds later** (measured). My first cut validated against the registry alone and the live test caught it — a tag that demonstrably existed on a task was rejected as unknown. An unknown tag still raises rather than returning `[]` (so a typo never masquerades as "nothing carries it"), but the known set is now the registry **∪ the names in use** on the listed items. A genuine typo matches neither. ## Nested tags are opt-in Confirmed live: an item tagged with a child does **not** carry the parent — all 9 `steady_🟢` carriers lack `project_status`. So an exact parent query returns only items tagged with the parent itself; `include_child_tags=true` walks parent links downward, transitively. This answers the issue's open question directly, and gives the rotation the "find every carrier" primitive it needs: `project_status` + children finds all 14. ## Answering the acceptance list - **Match on display `name`** ✅ — with a regression test against real drifted tags - **Nested tag behaviour documented** ✅ — opt-in, transitive, DESIGN.md §13 - **Completed tasks documented** ✅ — excluded by default, `include_completed=true` reaches them; the strip step needs it or a tag survives on a closed item ## Testing `178 passed`, plus 7 live tests. Contract in DESIGN.md §13. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012VU4wBhFAAosZLQcWqvHsf
Closes #5.

Filtering is client-side over the full state already fetched, so no new
endpoint. Matching is on the lowercased display `name`, which is also exactly
what appears in an item's `tags` array.

The issue's raw_name warning is well founded and now has a regression test: p1
reports rawName "top_3", p7 reports "weekly_priorities", project_status reports
"status". A live test asserts that every tag whose raw_name has drifted from its
name fails to resolve by raw_name.

Nested tags are opt-in. Confirmed live that an item tagged with a child does NOT
carry the parent — all 9 steady_🟢 carriers lack project_status — so an exact
parent query returns only items tagged with the parent itself.
include_child_tags walks parent links downward transitively.

Two findings that changed the design:

1. list_notes needs the same filter. list_tasks excludes notes, and in this
   estate the status tags are held EXCLUSIVELY by notes: steady_🟢 matches 0
   tasks and 9 notes. A strip-then-apply rotation sweeping only tasks would
   silently leave every carrier tagged, which is the accumulation the strip step
   exists to prevent. Both methods now take tag/include_child_tags.

2. The tag registry lags tagging. A tag applied to an item appears in that
   item's tags array immediately but reaches the account tag list only ~6s
   later. An unknown tag still raises rather than returning [] — so a typo can
   never look like "nothing carries it" — but the known set is the registry
   UNION the names in use on the listed items, so a freshly applied tag is not
   falsely rejected.

Also surfaces Tag.parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VU4wBhFAAosZLQcWqvHsf
Closes #6.

Endpoint found by probing: POST column with the same
{"add": [...], "update": [], "delete": []} envelope the other batch endpoints
use. The near-misses are recorded in DESIGN.md §14 so nobody repeats the search
— batch/column is a 404, POST column/project/{id} is a 405, POST column with a
bare object returns an empty envelope and silently creates nothing, and a JSON
array body is a 500. Only the bare `column` path with a batch body works.
Columns do not require the project to be in kanban view.

create_column is idempotent by name, case-insensitively: an existing column of
that name is returned rather than duplicated. That guard is load-bearing rather
than defensive — verified that TickTick DOES create a second column with the
same name when asked, producing ["Doing", "Doing"] and splitting one logical
section in two. Same shape as the group/column guards in §12: the API has no
validation, so ours is the only one.

The returned Column carries the id, so a caller can place cards into a
brand-new column in the same run without a second lookup. A live test asserts
that end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VU4wBhFAAosZLQcWqvHsf
Covers the nine-issue batch: wire timestamps and folder groups (#1, #8, #3),
project placement, sort order, column guards and note append (#2, #9, #7, #4),
tag query (#5), and column creation (#6).

Sits at the top of the PR stack so main is release-ready the moment the stack
lands — tagging v0.5.0 then triggers the Woodpecker image publish, which is what
actually puts the new tools on the deployed gateway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012VU4wBhFAAosZLQcWqvHsf
Reviewed-on: #13
ethan merged commit 6644e597a0 into feat/write-side-gaps 2026-08-09 20:38:54 -05:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
halfst/ticktick-mcp!12
No description provided.