You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
get_todo_by_id now reports evening: true for a to-do scheduled for This Evening, instead of silently dropping that state (bead hq-wsa.9). when='evening' was previously invisible in every read tool's output - things.py's own SELECT never exposes TMTask.startBucket, so an Evening-scheduled to-do was indistinguishable from a plain when='today' to-do (both report only start='Anytime'/startDate=<today>). get_todo_by_id now reads startBucket for the single resolved to-do via a narrow, read-only (mode=ro URI) raw-SQL side channel (_read_start_bucket), gated by a cheap pre-filter (only queried when the item is a to-do with its own start_date set - never for projects/areas/headings, and never for a to-do with no start date at all) - startBucket == 1 adds evening: true to the result; any other value, or any error reading it (missing/locked database, schema change, etc.), silently omits the key rather than failing the lookup. Deliberately scoped to get_todo_by_id only - no list tool (get_today, get_todos, etc.) gained this field, to avoid multiplying this raw SQL connection per row. See CLAUDE.md's date-formats bullet 5 and "Todo field lists per mode" section for the updated contract.
get_todo_by_id now resolves trashed state transitively through a to-do's/heading's containing project, instead of reporting a live-looking item that is actually unreachable (bead hq-wsa.7). Things marks only the trashed container (e.g. a project moved to Trash), not each descendant - a to-do or heading filed under a trashed project carried no trashed key of its own in things.py, so get_todo_by_id previously reported it as a normal, live item. _get_todo_by_id_sync now resolves the container chain with at most two things.get() calls (item's own project, or - for a to-do under a heading with no project of its own - the heading's project) whenever the item itself is not directly trashed; if that container is trashed, the result now carries both trashed: true and a new trashedViaParent: true key, distinguishing "unreachable via a trashed ancestor" from direct trash (which keeps the existing trashed: true-only shape). Areas cannot be trashed, so there is no area hop; a failed container lookup (e.g. unreadable database) omits both keys rather than failing the lookup. Single-item lookup only - list tools already exclude trashed items via things.py's own filtering and are unaffected. See CLAUDE.md's get_todo_by_id section for the updated contract.
Things URL-scheme auth token no longer requires a server restart to pick up a newly-added or fixed token file, and its failure/status is now diagnosable (bead hq-wsa.4). AppleScriptManager previously loaded the auth token exactly once, in __init__; a .things-auth/things-auth.txt/~/.things-auth file created (or an empty one fixed) after the server started had no effect until the process was restarted, and neither health_check nor get_server_capabilities exposed whether a token was actually loaded, so the only way to tell was to trigger an auth-gated write and read its error. Three changes: (1) reload-on-miss - every auth gate (the shared AppleScriptManager.execute_url_scheme gate, plus the four call sites in scheduling/todo_operations.py/tools_helpers/bulk_operations.py that check the token ahead of a URL-scheme call for heading/when='evening'/when with a @HH:MM time component) now calls a new AppleScriptManager.reload_auth_token_if_missing() immediately before failing, which re-reads the candidate paths only when no token is currently loaded (a token, once loaded, is never unloaded or re-read) - a token file added or fixed after startup now works on the very next auth-gated call, no restart needed. (2) runtime visibility - health_check and get_server_capabilities (current_status) now report auth_token_configured: <bool>, the live runtime state, alongside the existing static url_scheme_support: true build-capability flag on get_server_capabilities (unchanged). (3) resolution trace - _load_auth_token now also returns a checked_paths trace ([{"path": ..., "status": "matched"|"empty"|"missing"|"unreadable"}, ...], one entry per candidate path in search order, path only - never the token value) which is included as a checked_paths field on every AUTH_TOKEN_NOT_CONFIGURED error, forwarded verbatim through _propagate_url_scheme_error (the checklist tools' and update_todo's heading-move forwarding path) alongside the existing hint. See CLAUDE.md/README's "Things URL-scheme auth token" sections for the updated (no-restart) semantics.
AppleScriptExecutor's serialization lock is now per-event-loop, fixing a latent cross-loop poisoning hazard (bead hq-yxu). AppleScriptExecutor._applescript_lock was a single class-level asyncio.Lock() created at import time. CPython's asyncio.Lock only binds its internal loop reference on a CONTENDED acquire (a second waiter queueing while the lock is already held) - an uncontended acquire never binds. Once a contended acquire happened on one event loop, the lock became permanently bound to that loop; a later contended acquire from a different loop (e.g. a fresh asyncio.run() per call, as the regression test harness does) then raised RuntimeError: ... is bound to a different event loop instead of serializing - a bulk_move_records call with max_concurrent > 1 reliably creates that intra-loop contention. The lock is now keyed by the currently running event loop in a weakref.WeakKeyDictionary, mirroring hq-5xa's operation_queue fix exactly: each loop lazily gets its own Lock instance via a new AppleScriptExecutor._get_lock() classmethod, so within-loop serialization is preserved while cross-loop calls never touch a foreign-bound lock. This unblocks hq-wsa.6 (whose move_operations.py change deterministically triggers the poisoning without this fix).
move_record/bulk_move_records now report the true pre-move original_location and a clean, unlabeled title in the success message (bead hq-wsa.6). MoveOperationsTools._get_todo_info previously round-tripped through AppleScript via a getCurrentLocation helper that was a stub always returning the literal string "inbox", and parsed the script's positional "id, name, notes, status, current_list" output with a fragile output.split(", ") - so original_location always reported 'current_list:inbox' regardless of the to-do's true origin, and the success message's title carried a stray 'name:' label (e.g. "Todo 'name:My Task' moved to ..."). _get_todo_info now reads the to-do via things.get(todo_id) instead: title/notes/status come straight from the clean field, and a new _derive_original_location helper reports the real prior location - 'project:<id>' for a to-do filed directly in a project or under a heading (resolved via the heading's own record, mirroring read_operations._fill_project_from_heading), 'inbox'/'someday'/'anytime' for the corresponding Inbox/Someday/Anytime start states, 'today' for an Anytime to-do whose start date is today or earlier (matching Things' own Today-list membership rule), and the literal ISO date string (e.g. '2026-09-01') for an Anytime to-do with a future start date (chosen over inventing an 'upcoming'-style token, since that isn't a valid move_record destination itself). If the things.get() lookup itself raises (DB unreadable / Full Disk Access missing), the move now proceeds anyway with original_location omitted entirely from the response (not present as null) and the raw id used in the message, matching the existing DB-unreadable fallback convention used by add_todo's list_id resolution. The pre-move existence check retains a short bounded retry (two 250ms re-checks) before reporting TODO_NOT_FOUND, so the existing hq-c7a concurrency-race tolerance for a just-created todo isn't worsened by this AppleScript-to-things.py switch. bulk_move_records shares this same code path (each id delegates to move_record under the hood) and is fixed identically with no field-shape change. One additional safety guard rides along: an id that resolves to something other than a to-do (e.g. a project uuid, which AppleScript's to do id lookup would previously have resolved and moved) now returns TODO_NOT_FOUND instead of moving the wrong item, mirroring hq-wbm's update_todo pre-check.
BREAKING (minor): search summary envelopes no longer carry a misleading total_matches key (bead hq-wsa.5). ProgressiveDisclosureEngine._summarize_search_results (context_manager.py) emitted total_matches: len(results), but the data it receives is already limit/offset-truncated by the time it gets there (server.py passes the final window down) - so e.g. limit=1 yielded total_matches: 1 even though the true, pre-limit match count (correctly reported by the envelope's separately-injected total) was much larger. total_matches is now dropped entirely rather than threaded through as a correct value; total was already correct in every mode and is the sole source of truth for the pre-limit match count. search_results_breakdown (a per-status breakdown of the returned window) is unaffected - it's explicitly window-scoped, not total-sounding. Callers reading total_matches off a search_todos/search_advancedmode='summary' response must switch to total.
get_projects(mode='summary') no longer always reports active: 0 (bead hq-wsa.1). ProgressiveDisclosureEngine._summarize_projects (context_manager.py) counted p.get('status') == 'open' for the active count, but things.py/convert_project emit status == 'incomplete' for open projects, never the literal string 'open' - on a live database this meant active was always 0 regardless of how many open projects existed. _summarize_projects now builds a dynamic status_breakdown from the rows' actual status values (same pattern already used by _summarize_todos) and derives active = status_breakdown.get('incomplete', 0), completed = status_breakdown.get('completed', 0), and a new canceled = status_breakdown.get('canceled', 0) field. The summary envelope's key set grows from {active, completed, recent_projects} to {active, completed, canceled, status_breakdown, recent_projects} - existing active/completed/recent_projects keys are unchanged in shape, canceled and status_breakdown are additive.
BREAKING (minor): structured envelopes no longer double-serialize the item payload under a second, legacy key (bead hq-wsa.2). ThingsMCPServer._read_result's dict branch extracted items from whichever source key was present (data from optimize_response, or a summary-preview key: recent_preview/recent_projects/result_preview, or get_tag_usage's tags rows list) but then kept that source key in the returned envelope via dict(response) while also setdefault-ing the same list object under items - every optimized read carried the identical item list twice (byte-identical: result['data'] is result['items'] was True). The source key is now popped once items has been populated from it, so items is the one canonical payload array; payload size on affected reads drops by roughly half. Clients that re-parsed the raw data/recent_preview/recent_projects/result_preview/tags keys directly (rather than the documented items contract) must switch to items - the documented contract itself is unchanged, and count/total/mode/requested_mode/limit/offset/meta/truncated/truncation_hint are untouched. get_tags() itself is unaffected (it's a bare-list response, routed through the list branch, not the dict branch this fix touches).
Changed
get_due_in_days/get_activating_in_days now accept mode/limit and route through the same response-optimization pipeline as the other date-window list tools (bead hq-wsa.3). Previously both tools passed their full, unfiltered convert_todo list straight to ThingsMCPServer._read_result's list branch, bypassing context_manager.optimize_response/_apply_field_filtering entirely - notes and every other field shipped untrimmed regardless of size, the envelope always reported mode: 'standard' even though the rows were effectively DETAILED-shaped, and there was no way to cap a large overdue backlog. Both tools now accept mode: Optional[str] (auto/summary/minimal/standard/detailed/raw, validated via the shared _validate_mode helper) and limit: Optional[int] (1-500), fetch the full window first so total reflects the pre-limit count, slice to limit client-side, and route the result through context_manager.optimize_response exactly like get_anytime/get_someday (both added to SmartDefaultManager.DEFAULT_MODES/DEFAULT_LIMITS with AUTO/40 respectively, matching get_anytime's defaults). Envelope contract change: these two tools leave the "no mode parameter" group - requested_mode now echoes the caller's actual request (None/'auto'/a concrete mode) instead of always being None; mode continues to report the concrete resolved mode. include_overdue/days behavior is unchanged. See CLAUDE.md's "Due/activating date-window tools" section for the updated contract.
Documentation
Documented that checklist-only edits do not bump a to-do's modificationDate (bead hq-wsa.8). Verified live: add_checklist_items/prepend_checklist_items/replace_checklist_items are thin things:///update calls, but Things tracks a checklist item's own userModificationDate on TMChecklistItem independently of the parent TMTask row - three consecutive checklist writes left the parent to-do's modificationDate pinned, while a heading move (which rewrites the task row) advanced it as expected. This is upstream Things behavior, not a bug in this server. Added a warning to the add_checklist_items/prepend_checklist_items/replace_checklist_items docstrings and add_todo's checklist_items parameter description, and a matching note in CLAUDE.md's "Managing Checklist Items" section: change-detection consumers polling modificationDate will not observe checklist-only edits - compare checklist content via get_todo_by_id(include_items=true) instead. tests/live/test_smoke.py::test_checklist_tools_require_auth_token_or_roundtrip now also asserts modificationDate is unchanged across a checklist write when a token is configured, so an unexpected upstream behavior change would be caught.
Documented the upstream Things completion-cascade asymmetry on update_project (bead hq-wsa.7). update_project(id=..., completed="true") cascades completion to a project's child to-dos (matching the Things app), but completed="false" does not cascade a reopen back to those children - confirmed this repo's AppleScript write is a single set status of targetProject ... with no child-iteration logic, so the asymmetry is a Things/AppleScript behavior, not a bug here. Documented in CLAUDE.md's status-semantics truth-table section and in README's update_project tool entry.