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
when='today' (and an explicit ISO date equal to today) now schedules into the Anytime list, not an "unconfirmed" Someday state, and is visible in get_anytime (bead hq-x9z). add_todo, update_todo, bulk_update_todos, add_project, and update_project share the same AppleScript scheduler (scheduling/strategies.py's SchedulingStrategies), which previously scheduled when='today' via schedule theTodo for (current date) - Things' own "schedule" AppleScript verb, confirmed live, always leaves the to-do in its unconfirmed/yellow-dot state (things.get() reports start='Someday', start_date=today), which shows up in things.today()/get_today (whose own predicate explicitly includes that state) but not things.anytime()/get_anytime - unlike the Things URL-scheme when='today' path (checklist/heading/evening adds), Things' own "Today" UI button, or the equivalent move_record(destination_list='today'), all of which yield start='Anytime'. The scheduler now issues move theTodo to list "Today" instead of the schedule verb whenever the resolved date is today (confirmed live via things.get() reporting start='Anytime', start_date=today, and membership in both things.today() and things.anytime()); schedule is unchanged for any other date (e.g. 'tomorrow' or a future ISO date), where Things' own start='Someday' + future start_date representation is normal and expected (things.upcoming() explicitly keys off exactly that state).
when='anytime' now schedules into the Anytime list, not Someday (bead hq-z5d). add_todo, update_todo, bulk_update_todos, add_project, and update_project all share the same AppleScript scheduling fallback (scheduling/helpers.py's determine_target_list), which previously mapped the literal string 'anytime' to the same "Someday" list-move target as 'someday' - a to-do/project scheduled with when='anytime' ended up with start='Someday', indistinguishable from a native when='someday' item. It now maps to "Anytime", matching CLAUDE.md's documented behavior and Things' own Anytime list (move theTodo to list "Anytime", confirmed live via things.get() reporting start='Anytime', start_date=None).
when='YYYY-MM-DD@HH:MM' now actually sets a reminder (bead hq-4gn). add_todo, update_todo, bulk_update_todos, add_project, and update_project all accepted this date+time form (ParameterValidator.validate_date_format allows the @HH:MM suffix), but the AppleScript scheduling path (SchedulingStrategies.schedule_todo_reliable -> locale_aware_dates.normalize_date_input) only ever extracted year/month/day, so the time component was silently dropped and no reminder was ever set. when values with a @HH:MM component are now routed through the Things URL scheme instead (which supports this form natively and sets reminder_time), the same way when='evening' already was: add_todo via things:///add (no auth token required); update_todo/bulk_update_todos via things:///update (requires the Things auth token, checked before any AppleScript write so a missing token never partially applies other fields in the same call). Unlike evening, this form IS supported for projects (live-verified against things:///add-project/things:///update-project, both of which set a project reminder natively) - add_project/update_project route it via things:///update-project (also auth-token-gated). Also fixes a related validation gap: ParameterValidator.validate_date_format previously accepted any @H:MM/@HH:MM-shaped string without validating the hour/minute ranges (e.g. '2026-01-01@25:99' passed validation) - it now rejects out-of-range times with INVALID_WHEN, the same as any other malformed when.
Move destinations
move_record/bulk_move_records now reject destination_list='upcoming' at validation instead of accepting it and later failing with APPLESCRIPT_ERROR (bead hq-cag). Things has no direct "Upcoming" move target - an item is Upcoming by having a future start date, and Things' own AppleScript move verb rejects move ... to list "upcoming" with "Cannot move to-do". Rather than silently guessing an arbitrary future date, 'upcoming' is no longer accepted as a destination: move_record returns VALIDATION_ERROR and bulk_move_records returns INVALID_DESTINATION (validated once up front, before any per-todo move is attempted, so nothing in the batch moves), both with a message pointing callers at update_todo(id=..., when='<YYYY-MM-DD>') (or when='tomorrow') to schedule the to-do for a future date instead.
move_record/bulk_move_records now actually route destination_list='logbook'/'trash' instead of always returning INVALID_DESTINATION (bead hq-edj). MoveOperationsTools._validate_destination already accepted 'logbook' and 'trash' in its valid_lists, but _execute_move's destination-routing if/elif chain only recognized ['inbox', 'today', 'upcoming', 'anytime', 'someday'] as built-in list moves - 'logbook' and 'trash' fell through to the else branch and returned INVALID_DESTINATION even though validation had accepted them (both move_record and bulk_move_records share this code path, so both were affected). 'trash' now uses the same move theTodo to list "trash" verb already used for the other built-in lists (live-verified). 'logbook' has no direct move target in Things' AppleScript dictionary - the only documented way an item reaches the Logbook is completion - so it now issues set status of theTodo to completed (live-verified: reports status='completed' and membership in things.logbook()).
Structured envelope / response contract
BREAKING: mode='summary' preview rows (recent_preview/recent_projects/result_preview) now emit the documented SUMMARY field set instead of {id, name} (bead hq-9tm). get_todos/get_today/get_inbox/get_upcoming/get_anytime/get_someday/get_projects/search_todos/search_advanced under mode='summary' build their preview items via ProgressiveDisclosureEngine._summarize_todos/_summarize_projects/_summarize_search_results, which previously hand-built each preview row as {"id": <uuid>, "name": <title>[:50]} - contradicting CLAUDE.md's "Todo field lists per mode" SUMMARY contract of {uuid, title, status, tags, dueDate} (and the parallel PROJECT_FIELD_SETS entry, which is identical). Preview rows now go through the same field-filtering convention as every other mode: each row is built from TODO_FIELD_SETS[SUMMARY]/PROJECT_FIELD_SETS[SUMMARY] (dispatched per-row by the item's own type, matching _apply_field_filtering's existing per-row logic), so {uuid, title, status, tags, dueDate} are present when non-null on the source item and absent otherwise (fields are never invented) - id/name no longer appear at all. Any caller reading id/name off a summary-mode preview row must switch to uuid/title.
List tools no longer silently drop items via relevance-ranked pagination when the ~80KB response-size budget is exceeded (bead hq-cal.2). ContextAwareResponseManager.optimize_response (context_manager.py) enforced the budget by re-ranking items with a relevance heuristic and returning only the top-scoring subset - this fired even when the caller passed no limit, and total still reported the full pre-truncation count, so a freshly created but low-relevance item (e.g. a new project on a large Anytime list) could be unreachable via that list tool even though it appeared to have been requested in full (list tools like get_anytime/get_today/get_upcoming/get_someday have no offset). _handle_oversized_response now keeps a deterministic prefix of the list in its original order (the order the tool produced it in, i.e. things.py order) instead of relevance re-ranking, and the structured envelope now carries an explicit truncated: true + truncation_hint (a short string explaining how to reach the rest, e.g. via a smaller mode/limit/more specific filter) whenever this budget truncation fires - both keys are absent (not false) when it doesn't, so untruncated envelopes remain byte-stable. This propagates through ThingsMCPServer._read_result to structured_content for every list tool that routes through optimize_response (get_today/get_inbox/get_upcoming/get_anytime/get_someday/get_todos/get_projects/etc.). See CLAUDE.md's Structured Output section for the full contract.
search_todos(mode='summary')/search_advanced(mode='summary') now return a non-empty items preview instead of always items=[] (bead hq-cal.4). ThingsMCPServer._read_result's summary-preview fallback checked recent_preview/recent_projects/tags/top (the preview keys used by the todo/project/tag summarizers) but never result_preview, the key ProgressiveDisclosureEngine._summarize_search_results actually populates - so search summaries always resolved to an empty preview list even though count/total were already correct. result_preview is now included in the fallback chain.
get_logbook/get_due_in_days/get_activating_in_days/get_tags/get_tagged_items/get_recent now carry requested_mode in structured_content, matching CLAUDE.md's documented uniform envelope contract (bead hq-lsb). ThingsMCPServer._read_result's list branch (taken by these six tools, which each pass a raw list rather than the {"data": ..., "meta": ...} dict shape context_manager.optimize_response produces) only ever set items/count/total/mode/limit/offset, never requested_mode - the key was silently absent from their structured_content, unlike every other list-returning read tool. None of these six tools has a mode parameter at all, so requested_mode now reports None (nothing was requested) while mode continues to report the effective/concrete shape ('standard') of the returned items - the same mode-vs-requested_mode distinction already documented for tools that do accept mode.
get_trash now carries requested_mode: None in structured_content, matching the other no-mode-parameter list tools (bead hq-cal.3). get_trash has no mode parameter, but routed through ThingsMCPServer._read_result's dict branch (server.py) without passing requested_mode=None explicitly, so it fell through to echoing mode='standard' back as requested_mode too - unlike get_logbook/get_due_in_days/get_activating_in_days/get_tags/get_tagged_items/get_recent, which were already fixed to report requested_mode: None by hq-lsb. get_trash's call site now passes requested_mode=None explicitly; mode continues to report the effective/concrete shape ('standard'). Audited every other _read_result call site in server.py - no other dict- or list-branch caller has this defect.
get_inbox/get_today/get_upcoming/get_anytime/get_someday now return the canonical structured invalid_mode error for a bogus mode instead of crashing with an opaque ToolError (bead hq-exd). These five list tools passed mode straight into ResponseMode(...) unguarded - a bogus mode string (e.g. mode='bogus') raised an unhandled ValueError, surfaced by FastMCP as a ToolError with no structured_content, unlike get_todos/get_projects/get_areas/get_project_headings/search_todos/search_advanced, which already returned read_error('invalid_mode', ...). The inline validation duplicated across those six tools is now a single shared ThingsMCPServer._validate_mode helper, applied uniformly to all eleven mode-accepting read tools so the check and error shape can never drift apart again.
Tag handling
bulk_update_todos(tags=...) no longer double-emits newly-created tags in the AppleScript tag string under the allow_all tag policy (bead hq-3bp). TagValidationResult.valid_tags already includes created_tags under ALLOW_ALL (TagValidationService._apply_policy extends valid_tags with the tags it auto-creates), but bulk_operations.py::_validate_bulk_params concatenated tag_validation['existing'] (mapped from valid_tags) with tag_validation['created'] again at the call site - so bulk_update_todos(tags='a,b') under ALLOW_ALL emitted 'a, b, a, b' to AppleScript instead of 'a, b' (Things itself deduplicates the applied tags, so the visible end-state was correct, but the emitted payload was wrong and wasteful). Fixed by using tag_validation['existing'] alone, which is already the complete, correctly-deduplicated set under every policy (ALLOW_ALL/FILTER_SILENT/FILTER_WARN/FAIL_ON_UNKNOWN - created_tags is always empty under the three filtering policies, so this is a no-op change there). The identical pattern existed in write_operations.py::_prepare_tags (shared by add_todo/update_todo/add_project/update_project/add_area/update_area/add_tags) but was masked there by a dict.fromkeys() dedup already applied to its result before use - fixed the same way for consistency, with no behavior change to its callers.
THINGS_MCP_TAG_CREATION_POLICY (env var / env_file) is no longer a dead configuration knob - filter_silent and fail_on_unknown are now reachable (bead hq-nb1). config.py's tag_creation_policy and ai_can_create_tags fields were cross-synced via a pair of field_validator(mode='before') validators whose order was determined by field-declaration order, not by which value the caller actually set: ai_can_create_tags (declared first) was always validated before tag_creation_policy, so tag_creation_policy's validator always found ai_can_create_tags already present and unconditionally derived the policy from that field alone, discarding whatever tag_creation_policy had been explicitly set to. As a result THINGS_MCP_TAG_CREATION_POLICY=filter_silent/fail_on_unknown (env var or env_file) silently collapsed to ALLOW_ALL/FILTER_WARN depending only on ai_can_create_tags, and the same collapse applied to constructing ThingsMCPConfig(tag_creation_policy=...) directly. Both field_validators are replaced with a single model_validator(mode='after') that reconciles the two fields using model_fields_set to detect which one(s) were actually provided (env var, env_file, or constructor kwarg) versus left at their defaults: an explicitly-set tag_creation_policy now always wins and is never silently overridden; ai_can_create_tags alone (policy unset) still derives the policy as before (True -> ALLOW_ALL, False -> FILTER_WARN); when both are explicitly set and conflict, tag_creation_policy wins and ai_can_create_tags is recomputed to match (policy == ALLOW_ALL <-> True), with a logged warning noting the conflict. All four policies (allow_all/filter_silent/filter_warn/fail_on_unknown) are now reachable via env var, env_file, and constructor kwargs alike. The declared default for tag_creation_policy is also corrected from fail_on_unknown to filter_warn in this same change: the pre-fix validator bug meant an unconfigured tag_creation_policy never actually resolved to its then-declared FAIL_ON_UNKNOWN default (it was always silently rewritten to FILTER_WARN, derived from ai_can_create_tags's default of False) - so every unconfigured deployment has always behaved as filter_warn in practice, matching CLAUDE.md's documented "Non-existent tags are silently filtered (no error)". Correcting the declared default to filter_warn means there is no actual behavior change for unconfigured users from this fix - only the previously-dead THINGS_MCP_TAG_CREATION_POLICY env var and explicit-conflict reconciliation are new.
create_tag no longer creates a blank-titled tag from a whitespace-only name (bead hq-r87). create_tag(' ') previously reached AppleScript unchanged - Things silently trimmed the whitespace and created a real tag with an empty title, unlike create_tag(''), which was already rejected with TAG_CREATION_FAILED at the AppleScript layer. create_tag now strips and rejects a whitespace-only tag_name with the same TAG_CREATION_FAILED error before any AppleScript call is made (checked after the ai_can_create_tags/TAG_CREATION_RESTRICTED gate, matching the existing empty-name check's position). add_tags's MCP-boundary tag parsing (server.py's _parse_tag_list) already filters whitespace-only tokens out entirely (if t.strip() per token) before they ever reach tag validation/AppleScript - reviewed and confirmed no separate fix was needed there; a whitespace-only add_tags(tags=...) request resolves to the pre-existing NO_VALID_TAGS error, not a blank tag.
Validation / write-safety pre-checks
update_todo/bulk_update_todos now pre-check the primary todo_id via things.py before any write, returning NOT_FOUND for an unknown id instead of an opaque APPLESCRIPT_ERROR (bead hq-wbm). AppleScript's to do id "<uuid>" unexpectedly also resolves a project uuid (Things treats projects as a "selected to do" class internally - verified live), so without this pre-check update_todo(id=<project-uuid>, ...) would silently rename/modify the project instead of failing; update_todo now rejects an id that resolves to anything other than a to-do with VALIDATION_ERROR (naming the actual type, e.g. "id 'X' is a project, not a to-do; use update_project() instead"), and a genuinely unknown id with NOT_FOUND - both returned before any AppleScript write, consistent with list_id/list_title resolution. bulk_update_todos pre-checks every id in the batch (batches are small - 2-50 ids per the documented optimal range) and excludes unresolvable ids from the AppleScript script entirely, reporting them in a new not_found list field in the response while still updating the valid ids; updated_count/failed_count/total_requested continue to reflect the full original request. If the things.py lookup itself raises (e.g. the Things database is unreadable / Full Disk Access missing), both tools fall back to proceeding with the write unchecked - the same documented fallback pattern as list_id's DB-unreadable case. delete_todo already resolved id types via things.py and required no change.
add_project/update_project no longer orphan a project when area_id/area_title is unresolvable (bead hq-rmh). _build_create_project_script (add_project) and update_project's AppleScript both ran the whole write - including set area of ... to area "<title>" - inside a single try block with no transactional rollback: an unresolvable area threw partway through, so add_project created a real, un-areaed orphan project (and update_project silently discarded every other field in the same call - title/notes/tags/deadline/status) while still reporting APPLESCRIPT_ERROR. Both now pre-resolve area_id/area_title via things.py (TodoOperations._resolve_area, mirroring add_todo's existing list_id/list_title pre-resolution) before any write: an unknown area_id/area_title returns NOT_FOUND and creates/changes nothing; an area_title matching more than one area returns AMBIGUOUS_TARGET (with the matching ids); a successful area_title match is normalized to its concrete area_id before the script is built. If the things.py lookup itself raises (e.g. an unreadable Things database / missing Full Disk Access), the pre-bead behavior is preserved - the raw area_id/area_title is emitted unchecked rather than refusing the write, the same documented fallback add_todo's list_id already has. add_project's URL-scheme heading-creation path (_add_project_via_url_scheme, used when a todos payload contains ## lines) shares the same pre-resolution. add_todo has no public area/area_id/area_title parameter (only list_id/list_title, already pre-resolved) so it was not affected.
add_tags/remove_tags/move_record now reject an empty or whitespace-only todo_id with a structured VALIDATION_ERROR instead of sending it straight through to AppleScript (bead hq-a5j). add_tags/remove_tags (tools_helpers/write_operations.py) never validated todo_id at all - ''/' ' were embedded verbatim as to do id "" / to do id " " in the generated AppleScript, unlike update_todo/delete_todo, which already call ParameterValidator.validate_non_empty_string. Both now call the same validator first and return create_validation_error_response's VALIDATION_ERROR shape (field: "todo_id") before any AppleScript call. move_record's own _validate_move_inputs (move_operations.py) only rejected a falsy/empty todo_id, not a whitespace-only one (' ' previously passed through to the AppleScript move) - it now also rejects whitespace-only ids, and its VALIDATION_ERROR response carries field: "todo_id" for this case. create_tag's tag_name parameter now declares min_length=1 in its MCP tool schema, so an empty string ('') is rejected by pydantic at the tool boundary (a ToolError) rather than reaching the AppleScript layer as a no-op call; a whitespace-only name (' ') continues to be rejected by the existing runtime guard (TAG_CREATION_FAILED, bead hq-r87), unaffected by this change.
The documented 100-item checklist cap is now actually enforced (bead hq-exe). CLAUDE.md and the checklist tool docstrings stated "Maximum 100 checklist items per todo", but no code path checked the count before sending checklist-items/append-checklist-items/prepend-checklist-items to the Things URL scheme - 101+ items were silently accepted and created. add_todo(checklist_items=...), add_checklist_items, prepend_checklist_items, and replace_checklist_items (scheduling/todo_operations.py's new shared _check_checklist_item_count helper, MAX_CHECKLIST_ITEMS = 100) now reject a request whose checklist_items/items list (or newline-separated string, for add_todo) exceeds 100 with a structured {"success": false, "error": "TOO_MANY_CHECKLIST_ITEMS", "field": "checklist_items"|"items", "message": "checklist supports at most 100 items, got N"} error, checked before any AppleScript/URL-scheme write - exactly 100 items is still accepted, and replace_checklist_items(items=[]) still clears the checklist as before. This is a per-request limit only: Things gives no cheap way to read how many checklist items a to-do already has, so add_checklist_items/prepend_checklist_items do not count pre-existing items on the target to-do, only the items being submitted in the current call.
Infrastructure
AppleScript executor now retries rc=0 results whose stdout is the in-script "ERROR:"-prefixed convention, and a duplicate dead lock was removed (bead hq-c7a). Several AppleScript script builders (move_operations.py's project/area move scripts and _get_todo_info, tag_service.py's tag-creation script) catch a failure inside their own on error handler and return "ERROR: " & errMsg instead of letting osascript itself exit non-zero - so AppleScriptExecutor._execute_script_with_retry (services/applescript/executor.py), which previously only retried on returncode != 0, treated these as a successful result and never retried them, even though Things 3 intermittently errors under rapid, unpaced back-to-back AppleEvents (AppleScript execution IS serialized process-wide via AppleScriptExecutor._applescript_lock, held around every osascript call - the failures are a pacing/burst issue, not a missing-lock issue). The retry loop now also treats a successful (rc=0) result whose stripped stdout starts with the exact literal "ERROR:" as retryable, reusing the existing bounded retry count and exponential backoff; on final exhaustion the result is still returned exactly as produced (same shape existing callers already parse via output.startswith("ERROR:")), so the failure contract is unchanged. Also removes AppleScriptManager._applescript_lock (services/applescript_manager.py) - a duplicate asyncio.Lock() that was declared but never acquired anywhere in that class (dead code); the real process-wide serialization has always lived in AppleScriptExecutor._applescript_lock. Live-measured (tests/regression/test_bulk_and_move.py, two full runs plus a targeted third): this fix does not fully eliminate bulk_move_records' known concurrency-related failed_moves under load - a distinct, narrower residual failure mode remains at move_record's own _get_todo_info pre-check intermittently reporting a just-created, genuinely-valid todo as TODO_NOT_FOUND under concurrent AppleScript bursts; the regression suite's _bulk_move_tolerating_concurrency_race retry helper was tightened to retry only that specific TODO_NOT_FOUND signature (previously it retried on any failure) so a real regression still fails the suite instead of being silently masked.
operation_queue's singleton no longer hangs when called from a second event loop (bead hq-5xa). get_operation_queue() held a single module-level OperationQueue whose worker task was created via asyncio.create_task(...) inside start(), permanently binding that task (and anything it awaits) to whichever event loop was running at creation time - a later call from a different event loop (e.g. an MCP client doing a fresh asyncio.run() per call) reused that stale worker task and hung awaiting/restarting it against a foreign, possibly-closed loop; reproduced live with as few as two sequential bare asyncio.run(get_operation_queue()) calls (hung at the second loop's teardown). Fixed by keying the queue by the currently-running event loop (asyncio.get_running_loop()) in a WeakKeyDictionary, so each loop gets its own OperationQueue + worker task - no cross-loop teardown/await is attempted at all (single-loop behavior is unchanged: repeated calls within one loop still return the same instance). Note: an abandoned loop's dict entry is not actually garbage-collected by this alone - the still-running worker Task strongly references its own loop, pinning the WeakKeyDictionary key for the life of the process (a small, bounded leak: one inert entry per abandoned loop, not unbounded growth or a hang); shutdown_operation_queue() avoids this for callers that control their own loop lifecycle by explicitly popping and stopping the current loop's entry before the loop is discarded. tests/regression/test_utility_tools.py's queue_status/get_server_capabilities tests, previously batched into a single shared-event-loop test as a workaround for this hang (with a docstring that misattributed the mechanism to .done() itself rather than the cross-loop worker restart), are split back into normal independent per-test call_sync calls and the docstring corrected. Also adds pytest-timeout as a declared dev dependency (pyproject.toml's dev/test extras, requirements.txt) and installs it into the dev venv, and enables the previously-commented-out timeout = 300 in pytest.ini (the actually-effective pytest config in this repo - pyproject.toml's [tool.pytest.ini_options] is ignored whenever pytest.ini is present), so a future cross-loop-hang-style regression fails the suite with a timeout instead of wedging it indefinitely.
Documentation
search_advanced's scope semantics are now documented (bead hq-frf) - no behavior change. The tool's docstring and CLAUDE.md ("search_advanced scope semantics", under Tag Best Practices) now spell out three pre-existing, unchanged facts: (1) without an explicit type filter, only to-dos are searched - a bare area=/start_date=/deadline= filter can never return a project or heading, so pass type='project'/'heading' explicitly to search those kinds; (2) area= matches only items directly assigned to the area and does not cascade into to-dos living inside that area's projects - use get_todos(project_uuid=...) per project, or get_areas(include_items=true) to enumerate the area's projects first; (3) there is no project= filter parameter - use get_todos(project_uuid=...) to scope a search to one project.