@torsday/omnifocus-mcp 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +85 -10
  2. package/README.md +51 -7
  3. package/dist/index.js +3584 -1228
  4. package/package.json +8 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ All notable changes to `@torsday/omnifocus-mcp` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). See [ADR-0011](./docs/adr/0011-versioning-and-stability.md) for the explicit definition of breaking vs additive changes in this project.
6
6
 
7
7
 
8
+ ## [1.2.1](https://github.com/torsday/omnifocus-mcp/compare/v1.2.0...v1.2.1) (2026-04-30)
9
+
10
+ **Summary** — A focused reliability patch for OmniFocus 4.x compatibility and cross-transport ID interoperability. Seven bug fixes address real failure modes surfaced since v1.2.0: JXA scripts now correctly handle OF 4.x's quirky `class()` exceptions on tag parents and containing projects; `byId` misses are mapped to typed `NotFound` errors instead of leaking the raw `-1728` osascript code; and four write-path operations (`task_create`, `task_duplicate`, `task_reorder`, `project_move`) are routed through OmniJS to guarantee ID interoperability across all transport paths per ADR-0019. The `parentId` subtask filter also works correctly again after a `tasks()` vs `flattenedTasks()` regression. No breaking changes; all v1.2.0 call shapes are unchanged.
11
+
12
+ ### Fixed
13
+
14
+ - **JXA tag parent `class()` guard — OF 4.x exception safety** — `tag_list` and `tag_get` now guard the `parent.class()` call and per-element `tag.id()` calls against the `Can't convert types` exception that OmniFocus 4.x throws on certain specifier types. Previously these would surface as opaque JXA errors; they now degrade gracefully and return the tag without parent info rather than crashing the response. ([bcaefb9](https://github.com/torsday/omnifocus-mcp/commit/bcaefb9de9bdef4ce92876bf580eb2a48927c45f))
15
+
16
+ - **`byId` miss mapped to `NotFound` at the JXA boundary (closes [#674](https://github.com/torsday/omnifocus-mcp/issues/674))** — JXA's `flattenedTasks.byId()` returns a specifier with error code `-1728` ("Can't get object") when the ID doesn't exist, rather than `null`. This raw code was leaking through to callers. It is now intercepted at the transport boundary and converted to the typed `NotFoundError` the rest of the stack expects. ([ec53b88](https://github.com/torsday/omnifocus-mcp/commit/ec53b88e9733a3eaa24568da896d8a3da5ff377c))
17
+
18
+ - **`containingProject().class()` exception preserves `projectId`** — In OF 4.x, calling `.class()` on a real project specifier throws rather than returning a class name. A prior guard was catching the exception but resetting `projectId` to `null` as a side effect. Fixed: the exception path now correctly leaves `projectId` set to the project's ID. ([b40a4a0](https://github.com/torsday/omnifocus-mcp/commit/b40a4a0d77c0165c4d2ae6305ecfddf1efb49e18))
19
+
20
+ - **`parentId` subtask filter returns direct children only (closes [#695](https://github.com/torsday/omnifocus-mcp/issues/695))** — `task_list` with a `parentId` filter was calling `flattenedTasks()` on the parent, which recursively includes all descendants. Corrected to `tasks()` so only direct children are returned, matching the documented behavior. ([2796b30](https://github.com/torsday/omnifocus-mcp/commit/2796b30b9cf82090c67a4cbb0631f9e19712aaaf))
21
+
22
+ - **`task_create` routed through OmniJS for ID interoperability (closes [#680](https://github.com/torsday/omnifocus-mcp/issues/680))** — `task_create` previously used JXA, which assigns a different ID namespace than OmniJS. The returned task ID could not be reliably round-tripped through OmniJS write operations. Routed through OmniJS per ADR-0019 to guarantee ID interoperability across all transport paths. ([0c9959a](https://github.com/torsday/omnifocus-mcp/commit/0c9959abae2bb0e4afb0bdeb76bd60310ee141de))
23
+
24
+ - **`task_duplicate` routed through OmniJS for ID interoperability (closes [#692](https://github.com/torsday/omnifocus-mcp/issues/692))** — Same cross-transport ID issue as `task_create`. `task_duplicate` now runs via OmniJS and returns an ID that is valid for all subsequent operations regardless of transport. ([972ee8c](https://github.com/torsday/omnifocus-mcp/commit/972ee8c79154f523554e7e642a0262df1502dacb))
25
+
26
+ - **`project_move` and `project_create` routed through OmniJS + JXA folder readback fixed (closes [#681](https://github.com/torsday/omnifocus-mcp/issues/681))** — Both operations now run through OmniJS, fixing cross-transport ID interoperability failures. A secondary bug — JXA folder status/move setters silently no-oping — is also fixed. ([a1bf707](https://github.com/torsday/omnifocus-mcp/commit/a1bf70729738f3b1ec3534f0e82fdea7f72f5818), [509a6bd](https://github.com/torsday/omnifocus-mcp/commit/509a6bd4d4bdca71ae3d9b889f42ec44db55d195))
27
+
28
+ - **`task_reorder` validates parent before mutating (closes [#676](https://github.com/torsday/omnifocus-mcp/issues/676))** — `task_reorder` was applying the reorder even when the supplied `taskIds` belonged to different parent containers, producing silent data corruption. It now validates that all task IDs share the declared parent before any mutation. ([dc28308](https://github.com/torsday/omnifocus-mcp/commit/dc283086946d9418f7ea194136d5e91cb0e148a2))
29
+
30
+ ## [1.2.0](https://github.com/torsday/omnifocus-mcp/compare/v1.1.0...v1.2.0) (2026-04-29)
31
+
32
+ **Summary** — The headline additions are **outbound webhooks** (a full HTTPS + HMAC delivery subsystem that fires when OmniFocus state changes), **macOS Calendar integration** via a Swift EventKit bridge (with new `omnifocus://calendar` and `omnifocus://agenda` resources that merge calendar events with the OF Forecast view), **decision-journal** support (record user judgment on tasks/projects so agent-driven scans stop re-litigating the same anomaly), **natural-language perspective authoring** (a new MCP prompt + `perspective_create`/`update`/`delete`/`evaluate_dry_run` tools), **`task_defer_smart`** (intent-bearing defer-date grammar so agents stop landing tasks on weekends or 11 pm), and **mutation testing** wired in as a release-time hard gate (Stryker, calibrated baseline, fails publish on regression). Several existing surfaces were tightened — `task_extract_from_image` moved its post-parse validation rules into the Zod schema for cleaner error envelopes, several batch tools gained `.describe()` coverage on inner fields, and a handful of read-side responses now pair human-readable names with opaque IDs for the same reason v1.1.0 introduced the convention. Two new ADRs lock the architectures (ADR-0016 webhook delivery, ADR-0018 calendar bridge). No breaking changes; all v1.0.x / v1.1.x call shapes are unchanged.
33
+
34
+ ### Added
35
+
36
+ - **Outbound webhooks — `webhook_register`, `webhook_list`, `webhook_delete`, `webhook_test` (closes [#483](https://github.com/torsday/omnifocus-mcp/issues/483))** — first-class agent-observable event subsystem per [ADR-0016](./docs/adr/0016-webhook-delivery.md). Triggers cover **task-completed**, **task-created**, and **project-status-changed**, with optional per-trigger filters on `projectId` / `tagId`. Delivery is HTTPS-only at registration (http:// rejected), payload is JSON, optional HMAC-SHA256 signing via `X-OmniFocus-Signature: sha256=<hex>` (GitHub's header convention) when a secret is registered. Retry policy is **1s/5s/30s exponential backoff** (~36s total) on transport failure or non-2xx response, with a **per-webhook circuit breaker** that auto-disables a hook for **1 hour** after **10 consecutive failed deliveries**. Persistence lives at `~/Library/Application Support/omnifocus-mcp/webhooks.json` (mode 0600, schema-versioned, atomic tmp+rename writes). The whole subsystem is **off by default** behind `OMNIFOCUS_WEBHOOKS_ENABLED=1`, mirroring `OMNIFOCUS_ALLOW_RAW_SCRIPT`. URLs and HMAC secrets are stored on disk only — never echoed back through any tool response or surfaced on `omnifocus://capabilities` (which gains a `webhooks: { enabled, count, names }` field for visibility without leakage). `webhook_test` fires a synthetic event through the same HTTPS + HMAC + retry + circuit-breaker path as a real delivery — if the receiver doesn't see the synthetic, real events won't reach it either. Real-event firing rides the existing `DatabaseWatcher` chain: every detected OF state change feeds a fresh full snapshot to the orchestrator's diff/dispatch loop, with a `shouldObserve()` fast path so the snapshot fetch is skipped entirely when no webhook is registered (zero overhead for the default case). Failure-mode discipline (ADR-0016 §4e): every failure path is caught internally and logged to stderr, never propagates into the OF read path. The `node:https` import is allowlisted via the repo's network-import lint rule for `src/webhooks/` only.
37
+
38
+ - **macOS Calendar bridge + `omnifocus://calendar` and `omnifocus://agenda` resources (closes [#484](https://github.com/torsday/omnifocus-mcp/issues/484))** — read-only EventKit access via a tiny Swift subprocess bundled in `dist/`, per [ADR-0018](./docs/adr/0018-calendar-bridge.md). The Node-side `CalendarBridge` wrapper (`src/bridge/calendarBridge.ts`) exposes `ping()`, `getPermission()`, `requestAccess()`, and `readEvents(from, to, sources?)`. New typed errors: `CalendarPermissionDenied` (suggests granting Calendar access in System Settings) and `CalendarBridgeUnavailable` (suggests `pnpm build:calendar-bridge`). The `omnifocus://calendar{?from,to}` resource returns `{ events: CalendarEvent[] }` carrying `id, title, startsAt, endsAt, allDay, calendarName, calendarSource, location?, status, isAttendee?` — defaults span the current local-zone day, cached 60s on the `(from, to, sourcesEnv)` tuple. Calendar source filter via `OMNIFOCUS_CALENDAR_SOURCES` (read at request time so operators can change it without restarting). The `omnifocus://agenda{?date}` resource is the user-facing payoff — a sorted timeline of `{ items, floating }` where `items[]` interleaves calendar events and timed OF tasks by `startsAt` and `floating[]` is the bucket of OF tasks with no `dueDate`. The `omnifocus://capabilities` resource and `internal_status` tool both gain a `calendarAccess: { available, permission }` block: `available` is `true` when the bridge binary is callable, `permission` mirrors `EKEventStore.authorizationStatus(for: .event)` mapped to `granted | denied | restricted | not-determined`. Probes are read-only — they never trigger the macOS Calendar TCC prompt. ([#484](https://github.com/torsday/omnifocus-mcp/issues/484), [#637](https://github.com/torsday/omnifocus-mcp/issues/637))
39
+
40
+ - **Decision journal — `decision_record`, `decision_clear`, and project-health honor (closes [#485](https://github.com/torsday/omnifocus-mcp/issues/485))** — agent-memory of user judgment via a `decision-journal` fenced YAML block on a task or project note. `Decision` carries a `kind` from a closed set (`stall-is-intentional`, `deferred-by-choice`, `blocked-on-external`, `awaiting-decision`, `acknowledged-zombie`), a human-readable `reason`, an automatically-set `recordedAt`, and an optional `until` ISO-8601 auto-expiry (`isDecisionActive` returns false when `until` is in the past). The fence reuses the shared `noteFences` helper from #482 so `waiting-on` and `decision-journal` blocks coexist without conflict. Read-side integration: `task_get`, `task_get_many`, `project_get`, and `project_get_many` now surface a `decision` field whenever a fence is present (or a `decisions` map keyed by id on the `*_many` variants). The `omnifocus://project-health` resource partitions flagged projects into a new `acknowledged: ProjectHealthEntry[]` array when an active decision-journal fence is present, so callers can surface the user's recorded judgment ("Strategic pause until Q3 budget cycle") inline instead of re-litigating it. When `until` passes, the project re-emerges in `projects` automatically — the fence is preserved as audit history, never deleted. Malformed fences degrade silently. DESIGN.md §31 documents the fenced-metadata convention. ([#485](https://github.com/torsday/omnifocus-mcp/issues/485), [#589](https://github.com/torsday/omnifocus-mcp/issues/589))
41
+
42
+ - **Natural-language perspective authoring — `perspective-author` MCP prompt + `perspective_create` / `perspective_update` / `perspective_evaluate_dry_run` (closes [#476](https://github.com/torsday/omnifocus-mcp/issues/476), [#577](https://github.com/torsday/omnifocus-mcp/issues/577), [#659](https://github.com/torsday/omnifocus-mcp/issues/659))** — full authoring loop for OmniFocus custom perspectives. The new `perspective-author` MCP prompt turns a free-text description ("everything I could do at home, on a phone, with under 15 minutes") into a saved perspective via a three-step flow: (1) propose a `PerspectiveRule[]` tree from the prose, (2) preview matched tasks via `perspective_evaluate_dry_run`, (3) save via `perspective_create` only after user confirmation. The prompt embeds a reference card of every rule-tree atom (`actionAvailability`, `actionStatus`, `actionHasAllOfTags`, `actionHasAnyOfTags`, `actionHasNoProject`, `actionHasDueDate`, `actionHasDeferDate`, `actionIsLeaf`, `actionIsProject`, `actionMatchingSearch`, `actionWithinFocus`) with three worked examples, so agents have the full vocabulary without web access. `perspective_create` lands a custom perspective via OmniJS with atomic rollback (a partial create is undone if any step fails), `perspective_update` patches name/aggregation/rules/iconColor without rebuilding from scratch. `perspective_evaluate_dry_run` previews a proposed rule tree without persisting it — implementation creates a temporary perspective with a sentinel name, evaluates it, and **always** deletes the temp inside one OmniJS execution so a transport-level retry between hops can't leave an orphan. Inputs flow through a strict `PerspectiveRuleInputSchema` with disjointness + strict-shape refinements at the boundary. Custom perspectives require OmniFocus Pro; built-in perspective IDs are rejected with a typed error. ([#476](https://github.com/torsday/omnifocus-mcp/issues/476), [#577](https://github.com/torsday/omnifocus-mcp/issues/577), [#617](https://github.com/torsday/omnifocus-mcp/issues/617), [#618](https://github.com/torsday/omnifocus-mcp/issues/618), [#619](https://github.com/torsday/omnifocus-mcp/issues/619), [#659](https://github.com/torsday/omnifocus-mcp/issues/659))
43
+
44
+ - **`task_defer_smart` + `task_batch_defer_smart` — intent-bearing defer-date grammar (closes [#479](https://github.com/torsday/omnifocus-mcp/issues/479))** — two new tools that wrap `task_update`'s defer path with a high-level intent so agents stop landing tasks on weekends or 11 pm. `DeferIntent` is a discriminated union with six variants: `next-work-day` (Mon if today is Fri/Sat/Sun, else tomorrow; at the configured morning or afternoon hour), `next-weekday: { weekday: 0..6 }` (next *strict* occurrence — today→full week away if the day matches), `in-business-days: { days: N }` (skips weekends; returns morning hour), `next-month-start` (first of next month, midnight), `explicit-with-skip-weekends: { date: ISO }` (snaps forward to Monday if the input lands on Sat/Sun), and `after-event: { eventId }` (gated on calendar bridge — currently throws a typed `CalendarBridgeUnavailable` for follow-up). Morning/afternoon defaults via env: `OMNIFOCUS_MORNING_HOUR` (default 9), `OMNIFOCUS_AFTERNOON_HOUR` (default 14). Resolution is pure (no I/O); tests inject `now` deterministically. The tool composes with `dry_run`, `idempotency_key`, and `expectedModifiedAt` like the rest of the write surface. Returns `{ taskId, resolvedDeferDate, reason }` so the agent can echo `"deferred to Mon 27 Apr 09:00 (next work morning)"` verbatim. The batch variant accepts `entries: [{ taskId, intent }]` and surfaces per-entry success/error rows so one malformed intent does not abort siblings. ([#479](https://github.com/torsday/omnifocus-mcp/issues/479))
45
+
46
+ - **`clarification-needed` response kind — third response variant for negotiation rather than guess-and-fail ([#493](https://github.com/torsday/omnifocus-mcp/issues/493))** — one of the five children of the NL-excellence epic (#491) lands as a new envelope variant. When a tool can't proceed without user-supplied disambiguation but the underlying request is structurally valid, it returns `{ kind: "clarification-needed", question, choices?, replayToken }` instead of throwing a validation error. The agent re-prompts the user, then replays the original call with the user's selection plus the `replayToken` so the server can correlate the second attempt with the first. Lets agents treat ambiguity as a conversation rather than an immediate failure, without losing the original input shape across the round-trip.
47
+
48
+ - **`project_template_delete` ([#588](https://github.com/torsday/omnifocus-mcp/issues/588))** — companion to v1.1.0's `project_template_save` / `_list` / `_instantiate`. Removes a saved template by name from the configured Templates folder, reporting `noChange:true` when the template was already absent so the call is idempotent across retries.
49
+
50
+ - **Mutation-score surface on `internal_status` (slice 1D of [#502](https://github.com/torsday/omnifocus-mcp/issues/502))** — the `internal_status` response gains `mutation: { score, lastRunAt } | null`. `score` is the live mutation score computed from `<package-root>/reports/mutation/mutation.json` using Stryker's standard formula `(killed + timeout) / (killed + survived + timeout + noCoverage)`; `lastRunAt` is the report file's mtime as ISO-8601. Returns `null` when no report is present — the published npm tarball ships without `reports/`, so end-user installs degrade cleanly while dev / CI clones surface live calibration freshness. Probe is read-only and synchronous; injectable via `InternalStatusContext.probeMutationScore` for tests.
51
+
52
+ - **NL-quality follow-ups — name-paired responses across remaining batch and review surfaces ([#571](https://github.com/torsday/omnifocus-mcp/issues/571), [#607](https://github.com/torsday/omnifocus-mcp/issues/607), [#608](https://github.com/torsday/omnifocus-mcp/issues/608), [#609](https://github.com/torsday/omnifocus-mcp/issues/609))** — extends v1.1.0's "pair human-readable name with opaque ID" convention to the remaining surfaces that hadn't yet been covered: every batch tool's inner `.describe()` lines, `task_find_similar` candidate rows, the `review_*` family + `project_mark_reviewed` + `project_set_next_review_date`, and `import_opml` / `import_taskpaper` owner names on the import-result rows. Same payoff as v1.1.0: agents echo the human-readable identifier without a follow-up `*_get` call. Closes [#601](https://github.com/torsday/omnifocus-mcp/issues/601).
53
+
54
+ ### Changed
55
+
56
+ - **`task_extract_from_image` — schema-discipline refactor (closes [#574](https://github.com/torsday/omnifocus-mcp/issues/574))** — closes the Class-5 finding from the NL-quality audit. The image-extension validity check (previously a runtime `ValidationError` thrown after Zod parse) and the `attachment-mode source requires attachSourceTo='none'` rule (likewise post-parse) are now expressed as Zod refinements at the input boundary, so violations surface as structured `ActionableValidation` failures keyed on the offending field rather than as opaque error throws partway through the handler. Inner fields on the `source` discriminated-union members (`attachmentId`, `ownerTaskId`, `ownerProjectId`) and the top-level `targetProjectId` gained `.describe()` lines per the rubric. No behavior change for valid inputs; tighter rejection (with structured errors) for invalid ones. The single-tool shape was kept — splitting into propose-then-commit tools was considered but rejected since #479's `task_defer_smart` and `repetition_from_prose` already model the `*_from_prose` pattern as single-tool.
57
+
58
+ ### Fixed
59
+
60
+ - **README — `omnifocus://intents` row no longer claims a tool count ([#645](https://github.com/torsday/omnifocus-mcp/issues/645))** — the README's intents-row mentioned a numeric tool count, which the repo's `no-tool-counts` lint gate prohibits (counts drift the moment new tools land). Restored gate compliance ([#646](https://github.com/torsday/omnifocus-mcp/issues/646)).
61
+
62
+ ### Documentation
63
+
64
+ - **ADR-0016 — Webhook delivery for OmniFocus state changes (closes [#662](https://github.com/torsday/omnifocus-mcp/issues/662))** — locks the architecture for [#483](https://github.com/torsday/omnifocus-mcp/issues/483)'s outbound webhook subsystem. Four decisions: (1) **trigger source** — polling-on-cache-refresh, riding the existing 30-second LRU cache (ADR-0006) rather than undocumented OmniJS observers or a new timer; lag bound by cache TTL is documented as a known property; (2) **persistence** — JSON config file at `~/Library/Application Support/omnifocus-mcp/webhooks.json`, mode 0600, schema-versioned, hot-reloaded via `fs.watch`; (3) **retry policy** — exponential 1s/5s/30s with a per-webhook circuit breaker (10 consecutive failures → auto-disable for 1h); best-effort delivery, no dead-letter queue; (4) **security model** — off by default behind `OMNIFOCUS_WEBHOOKS_ENABLED=1` (mirroring ADR-0004's escape-hatch discipline), HTTPS-only at registration, optional HMAC-SHA256 signatures using GitHub's header convention, capability resource exposes counts + names but never URLs or secrets, delivery failures log to stderr and never propagate into the OF read path. Status: Accepted.
65
+
66
+ - **ADR-0018 — Calendar bridge: EventKit only, Swift-binary subprocess** — formalises the architecture that unblocks [#484](https://github.com/torsday/omnifocus-mcp/issues/484) (calendar + agenda resources). Decisions: EventKit is the sole calendar substrate (third-party APIs handled by separate MCP servers, composed at the agent layer); access via a tiny Swift binary subprocess bundled in `dist/` (rejecting JXA/Calendar.app shim and direct Node FFI for documented reasons); read-only; permission UX mirrors the existing OF Automation prompt. Status: Accepted. ([#603](https://github.com/torsday/omnifocus-mcp/issues/603))
67
+
68
+ - **README — agent-native value-add lead (closes [#477](https://github.com/torsday/omnifocus-mcp/issues/477))** — new top-of-README section "Agent-native OmniFocus — beyond the app surface" frames the agent-unique capabilities (project-health triage, semantic dedupe, taxonomy audit, NL perspective authoring, time-budget reconciliation, retrospective, project templates, inbox-triage, calendar + agenda) ahead of the existing tool-list content. Honest split between mechanical aggregations the app could have shipped and capabilities only valuable with an LLM in the call path; closes the long-standing narrative gap that the README led with "wrapper" framing rather than the actual value-add.
69
+
70
+ - **README — Resources table refreshed for 24 URIs (closes [#643](https://github.com/torsday/omnifocus-mcp/issues/643))** — the README's MCP resources table had drifted to ten entries while the resource surface grew to twenty-four. Refreshed to the current set so users browsing the README see the actual capability surface.
71
+
72
+ - **DESIGN.md §31 — fenced note metadata convention ([#589](https://github.com/torsday/omnifocus-mcp/issues/589))** — formalises the fenced-YAML pattern that `waiting-on`, `decision-journal`, and `project-template` all share. New conventions filed under §31 so agents and contributors have one place to look for "how do we encode structured metadata in a free-text note without forcing the user to see it."
73
+
74
+ - **Stryker mutation-testing docs (slice 1E of [#502](https://github.com/torsday/omnifocus-mcp/issues/502))** — adds a `mutation-tested: stryker` badge to the README badge row (links to ADR-0017) and a "Mutation testing (release-time hard gate)" section to `CONTRIBUTING.md` covering local run command (`pnpm mutation`, ~6–7 min wall-clock), report locations, the equivalent-mutant policy per ADR §5 (default response to a survivor is to write the test; only observably equivalent mutations belong in `stryker-equivalents.json`, with a one-line rationale), the release-time gate placement, and how to query live calibration freshness via `internal_status`.
75
+
76
+ ### Build
77
+
78
+ - **Stryker mutation testing — installation, calibration, and release-time hard gate (slices 1A/1B/1C of [#502](https://github.com/torsday/omnifocus-mcp/issues/502))** — three slices land Stryker as a release-time quality gate per [ADR-0017](./docs/adr/0017-mutation-testing.md). Slice 1A added the dependencies (`@stryker-mutator/core`, `typescript-checker`, `vitest-runner`), `stryker.conf.json` with the ADR §2 mutator allowlist (`src/domain`, `src/errors`, `src/middleware`, `src/server`, tool input-validation schemas), the `pnpm mutation` script, and the `stryker-equivalents.json` scaffold with the §5 header convention. Slice 1B captured the calibration baseline — 2740 mutants instrumented across 35 source files, 6m42s wall-clock, mutation score **62.74%** — and set `thresholds.break = baseline − 5 = 57.74` per ADR §3. Three slice-1A defects were fixed during calibration: removed nonexistent `ignorers` plugin references, dropped two empty allowlist globs, and added an explicit `plugins:` array because pnpm's strict node_modules layout breaks Stryker's auto-discovery in spawned children. Slice 1C wired `pnpm mutation` into `release.yml` between `pnpm test` and `pnpm build`; the gate fails the release on any drop below `thresholds.break`, and the HTML + JSON reports upload as a `mutation-report-<tag>` workflow artifact (90-day retention, `if: always()` so a failed run still uploads). Runs once per release tag, not per PR.
79
+
80
+ - **Calendar bridge — Swift scaffold + build pipeline (slices 1–5 of [#484](https://github.com/torsday/omnifocus-mcp/issues/484))** — five slices land the Swift `calendar-bridge` subprocess and Node-side wrapper that the calendar/agenda resources ride on. Adds `tools/calendar-bridge/calendar-bridge.swift` and `scripts/build-calendar-bridge.sh` (mirroring `build-watcher.sh`: single-arch / `--all` for fat universal binary / `--verify` for typecheck-only). Build hooks: `pnpm build:calendar-bridge` and `pnpm build:calendar-bridge:all`. Binaries gitignored. Subcommands lay down progressively: `ping`, `permission` (read-only `EKEventStore.authorizationStatus(for: .event)` — does NOT trigger TCC prompt), `request-access` (calls `requestFullAccessToEvents` on macOS 14+, falls back to `requestAccess(to:completion:)` for older macOS — first invocation triggers the TCC prompt), and finally `calendar FROM TO` (event read via `predicateForEvents(withStart:end:calendars:)` + `events(matching:)` with hand-rolled JSON output). The Node-side `CalendarBridge` wrapper spawns the binary as a one-shot subprocess and parses one JSON line of stdout. Constructor accepts `{ binaryPath, spawn, existsSync }` overrides for tests, so the wrapper has full coverage on Linux CI without a built binary or TCC grant.
81
+
82
+ - **Bundle budget bumps for new subsystems** — bundle-size budget raised in step with new code: 610 → 625 KiB for the perspective-write tools (#577), then to 680 KiB through the webhooks subsystem (#483). Each bump landed with the slice that consumed it; the budget remains enforced in `release.yml` via `scripts/check-bundle-size.sh`.
83
+
84
+ - **Lint allowlist — `src/webhooks/` may import `node:https`** — the repo's `customRules.ts` `no-network-import` rule and `biome.json` `noRestrictedImports` were extended with a `src/webhooks/` allowlist so the dispatcher can call `node:https.request`. Per ADR-0016, outbound HTTPS is permitted only there.
85
+
86
+ - **CI — required-status-checks documentation ([#648](https://github.com/torsday/omnifocus-mcp/issues/648))** — documents which CI checks `main` branch protection requires, so contributors don't get blocked merging when an advisory check fails. No behavior change.
87
+
88
+
8
89
  ## [1.1.0](https://github.com/torsday/omnifocus-mcp/compare/v1.0.2...v1.1.0) (2026-04-28)
9
90
 
10
91
  **Summary** — The headline additions are project templates, waiting-on tracking, and several new analytics resources. Alongside new tools, the entire tool surface went through a focused NL-quality pass: every description now carries a worked `Example:` line, every mutation response pairs a human-readable name alongside the opaque ID, and enum inputs accept common aliases so loosely-worded agent inputs succeed rather than bounce. Agents calling this server cold will be able to compose correct calls with substantially less trial-and-error. No breaking changes; all v1.0.x call shapes are unchanged.
@@ -127,17 +208,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
127
208
 
128
209
  ## [Unreleased]
129
210
 
130
- ### Added
131
-
132
- - **`perspective_get` — read a custom perspective's full configuration** — returns `{ id, name, aggregation, rules, iconColor }` for a custom perspective by identifier. Surfaces the structured rule tree (`archivedFilterRules`) so agents can introspect what a perspective filters on without evaluating it. Routes via OmniJS — JXA exposes only `id`/`name`/`class` on perspective specifiers. Built-in perspective ids are rejected with a typed validation error since they have no rule tree. Custom perspectives require OmniFocus Pro. ([#523](https://github.com/torsday/omnifocus-mcp/issues/523))
133
- - **`perspective_delete` — delete a custom perspective by id** — removes a custom perspective via OmniJS `deleteObject` (JXA cannot delete custom perspectives). Built-in perspectives are rejected with a typed validation error. The tool invalidates the `perspective:*` cache scope so subsequent `perspective_list` reads return fresh state. ([#523](https://github.com/torsday/omnifocus-mcp/issues/523))
134
- - **Waiting-on tracking — synthetic dependencies via note fence** — new `task_set_waiting_on` and `task_clear_waiting_on` tools tag a task with the configured `@waiting` tag (created if absent; name configurable via `OMNIFOCUS_WAITING_TAG_NAME`, default `waiting`) and write/strip a fenced YAML metadata block (` ```waiting-on … ``` `) at the top of the task note. The fence preserves any existing user prose. `task_get` and `task_get_many` parse the fence and surface a structured `waitingOn: { whom, what?, since, followUpAfter? }` field on the response. New `omnifocus://waiting-on` resource aggregates every active task with a fence, sorted by `daysOverdue` descending (whole days past `followUpAfter`; `null` when unset or still in the future). Designed to systematize follow-ups OmniFocus has refused to model for a decade. ([#482](https://github.com/torsday/omnifocus-mcp/issues/482))
135
- - **Project templates — first slice (`save` + `list`)** — new `project_template_save` captures a project's task tree as TaskPaper into a new project under the configured `Templates` folder (env `OMNIFOCUS_TEMPLATES_FOLDER_NAME`, default `Templates`). Metadata (template name, parameter names, capturedAt) sits in a fenced `project-template` YAML block at the top of the template-project's note; the TaskPaper body sits below. The Templates folder is created lazily on first save. `project_template_list` enumerates every parseable template under that folder (sorted by capturedAt desc, name tiebreak). Duplicate template names within the folder are rejected with a typed `TemplateExists` error. Convention documented in DESIGN.md §30. `project_template_instantiate` (parameter substitution + relative-date shifting) and `project_template_delete` are intentionally out of scope this cycle and filed as follow-ups. ([#472](https://github.com/torsday/omnifocus-mcp/issues/472))
136
- - **`project_template_instantiate` — spawn a project from a saved template** — resolves a template by name within the configured Templates folder, validates that every recorded parameter has a value supplied (reports every missing name in one `MissingTemplateParameter` error), substitutes `{{name}}` placeholders, and shifts every `@due`/`@defer` date by the delta between the template's earliest `@due` and the supplied `dueDate`. Pre-creates the target project (optional `targetFolderId`, default library root) and hands the modified TaskPaper to the existing `importTaskPaper` flow. Returns `{ projectId, taskCount, importWarnings }`. Templates without an `@due` to anchor on instantiate as-is when `dueDate` is supplied (no error — there's nothing to shift). DESIGN §30 expanded with the substitution + anchor rules. ([#587](https://github.com/torsday/omnifocus-mcp/issues/587))
137
-
138
- ### Documentation
211
+ ### Fixed
139
212
 
140
- - **ADR-0018 Calendar bridge: EventKit only, Swift-binary subprocess** — formalises the architecture that unblocks [#484](https://github.com/torsday/omnifocus-mcp/issues/484) (calendar + agenda resources). Decisions: EventKit is the sole calendar substrate (third-party APIs handled by separate MCP servers, composed at the agent layer); access via a tiny Swift binary subprocess bundled in `dist/` (rejecting JXA/Calendar.app shim and direct Node FFI for documented reasons); read-only; permission UX mirrors the existing OF Automation prompt. Status: Accepted. ([#603](https://github.com/torsday/omnifocus-mcp/issues/603))
213
+ - **`moveProject` routes through OmniJS + JXA folder-readback survives OF 4.x quirk (closes #681)** — fourth fix in the [ADR-0019](./docs/adr/0019-cross-transport-id-interoperability.md) series. The first slice of #681 routed `createProject` through OmniJS but left `moveProject` on JXA, where `target.move({ to: folder.projects.end })` fails with "Attempted to move data objects to a nil container" on OmniJS-created project specifiers. New `src/scripts/omnijs/project_move.js` uses `moveSections([proj], destination)` and resolves the destination via `flattenedFolders.filter(...)` (or `library` for the root). Routing flips `moveProject: "jxa"` `"omnijs"`. Separately, the JXA folder-readback path on every project script (`project_get.js`, `project_get_many.js`, `project_list.js`, `project_create.js`, `project_update.js`) carried the same broken `f.class() !== "document"` guard that #673 already fixed for tasks: `f.class()` throws "Can't convert types" on a real Folder specifier in OmniFocus 4.x JXA, so the readback was silently returning `folderId: null` for every project in a folder. Replaced with the nested-try-catch pattern from #673 — treat the throw as "real folder", treat a successful return of `"document"` as the only skip path. The moveProject integration test now passes; project reads through JXA correctly surface `folderId` again.
214
+ - **`duplicateTask` routes through OmniJS for cross-transport ID interoperability (closes #692)** — third sibling fix in the [ADR-0019](./docs/adr/0019-cross-transport-id-interoperability.md) series after [#680](https://github.com/torsday/omnifocus-mcp/issues/680) (createTask) and [#681](https://github.com/torsday/omnifocus-mcp/issues/681) (createProject). JXA's `task.duplicate()` and `container.make({...})` produce transient specifier IDs that downstream OmniJS reads can't resolve. OmniJS's `duplicateTasks([source], position)` and `new Task(name, position)` produce clones whose `id.primaryKey` is interoperable with both transports. New `src/scripts/omnijs/task_duplicate.js` mirrors the JXA props-copy surface (name, note, flagged, defer/due dates, estimatedMinutes, sequential, tags) and resets completion state on the clone (matching the JXA contract). Recursive clones use `duplicateTasks` and walk the resulting subtree to clear inherited `completed` flags; non-recursive clones build a single fresh task via `new Task(...)` — naturally produces an uncompleted childless result. Routing flips `duplicateTask: "jxa"` → `duplicateTask: "omnijs"`. Three of four duplicateTask integration tests now pass (was 1 of 4). The recursive case partially passes — `descendantCount` correct, but its downstream `listTasks({ parentId })` assertion still trips on a separate pre-existing JXA filter bug where parentId returns grandchildren too. Will file a follow-up for that.
215
+ - **`createTask` routes through OmniJS for cross-transport ID interoperability (closes #680)** — sibling fix to [#681](https://github.com/torsday/omnifocus-mcp/issues/681). Per [ADR-0019](./docs/adr/0019-cross-transport-id-interoperability.md), JXA's `Task(props) + push()` returned a transient specifier ID that didn't match OmniFocus's persistent `id.primaryKey`, breaking subsequent OmniJS-routed downstream operations (`moveTask`, `reorderTask`, `duplicateTask`) which use the persistent key. New `src/scripts/omnijs/task_create.js` mirrors the JXA props-set surface (parent-task / project / inbox positions, note, flagged, defer/due dates, estimatedMinutes, tagIds, sequential, completedByChildren) and produces a task whose ID round-trips correctly across both transports. Routing-table flip: `createTask: "jxa"` → `createTask: "omnijs"`. Five of the seven named integration tests in #680 now pass: `createTask with projectId places the task in that project`, `moveTask into a project updates projectId`, and four `reorderTask` variants. The three `duplicateTask` failures and the `reorderTask validation when reference has different parent` failure trace to separate root causes (filed as follow-ups). Caller wrappers, OmniJsTransport contract, router exclusivity allowlist, and the routing-domain unit tests all updated to reflect the move; concurrent-test JXA-write fixtures now demonstrate via `updateTask` (still JXA-routed) since `createTask` is no longer the canonical example.
141
216
 
142
217
  ---
143
218
 
package/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
6
6
  [![Node: 24+](https://img.shields.io/badge/node-24%2B-brightgreen)](./package.json)
7
7
  [![Platform: macOS 13+](https://img.shields.io/badge/platform-macOS%2013%2B-lightgrey)](https://www.apple.com/macos/)
8
+ [![Mutation tested: Stryker](https://img.shields.io/badge/mutation--tested-stryker-orange)](./docs/adr/0017-mutation-testing-release-gate.md)
8
9
 
9
10
  > **Give any MCP-compatible AI assistant full, typed access to your OmniFocus.** Read your inbox, create tasks, close projects, batch-update dozens of items, evaluate perspectives, trigger sync — all through natural language. `omnifocus-mcp` wires an 80-tool MCP server directly to OmniFocus on macOS via JXA and OmniJS, with circuit breakers, rate limits, and an agent-aware error hierarchy so the assistant knows exactly what to do next when something goes wrong.
10
11
 
@@ -12,6 +13,7 @@
12
13
 
13
14
  ## Table of contents
14
15
 
16
+ - [Agent-native OmniFocus — beyond the app surface](#agent-native-omnifocus--beyond-the-app-surface)
15
17
  - [Why this exists](#why-this-exists)
16
18
  - [Quick start](#quick-start)
17
19
  - [Security & trust](#security--trust)
@@ -33,6 +35,26 @@
33
35
 
34
36
  ---
35
37
 
38
+ ## Agent-native OmniFocus — beyond the app surface
39
+
40
+ A plain MCP wrapper would be a one-to-one mirror of the OmniFocus app. This server is more than that. It exposes a small set of capabilities that exist *because* an LLM is the caller — capabilities the app itself doesn't ship and probably never will, because they're only worth the effort when the consumer is an agent that can reason over structured input and act on the result.
41
+
42
+ These are the agent-native capabilities, framed in the user outcome they enable:
43
+
44
+ - **Stalled-project triage** — `omnifocus://project-health` returns granular signals (last activity, available task count, deferred-future tasks, review-overdue) so an agent can identify projects worth a status nudge without the user opening the app. Mechanical aggregation; the app could do it but doesn't.
45
+ - **Semantic dedupe** — [`task_find_similar`](docs/tools.md#task_find_similar) does lexical similarity search across task names so an agent confirms intent ("is this a duplicate of X?") before creating a new task. Possible without an LLM, but only useful with one in the loop.
46
+ - **Taxonomy audit** — `omnifocus://taxonomy-audit` flags inconsistent tag/folder usage so an agent can propose cleanup grounded in the actual structure of the database. Mechanical.
47
+ - **NL perspective authoring** *(in development — [#476](https://github.com/torsday/omnifocus-mcp/issues/476))* — describe a perspective in prose; the agent compiles a rule tree and writes it via `perspective_create`. Exists *because* of the agent — the rule tree is a non-trivial structure most users won't compose by hand.
48
+ - **Time-budget reconciliation** — [`forecast_pack`](docs/tools.md#forecast_pack) takes a daily minute budget and packs the forecast into it, surfacing overloaded days. Asking "I have 90 minutes, what should I do?" gets a structured answer.
49
+ - **Retrospective resource** — `omnifocus://retrospective?from=…&to=…` aggregates the closed-task surface so an agent can write the user's weekly review against real data instead of asking them to recap.
50
+ - **Project templates** — [`project_template_save`](docs/tools.md#project_template_save) / [`_instantiate`](docs/tools.md#project_template_instantiate) capture and replay project structures with parameter substitution and date shifting. The agent fills the parameters from conversation context.
51
+ - **Inbox-triage prompt** — the bundled `inbox-triage` MCP prompt sequences the tool calls for a full GTD-style processing sweep. Intentionally a prompt, not a tool — the value is in orchestrating the existing surface.
52
+ - **Calendar + agenda** — `omnifocus://calendar` and `omnifocus://agenda` merge macOS Calendar events with the OF forecast so an agent can answer "what does my day actually look like?" without the user holding two windows side by side.
53
+
54
+ > **How this is different from a plain wrapper.** A wrapper exposes the app's verbs. This server adds verbs the app doesn't have, because LLMs change what's worth building. Some of the additions (project-health, taxonomy-audit) are mechanical aggregations the app *could* ship and never has — they sit unbuilt because no human wants to click through them. Others (NL perspective authoring, semantic dedupe, time-budget reconciliation) are only valuable with an LLM in the call path. Both kinds belong here. The split is honest: don't pretend the mechanical stuff is novel, and don't pretend the agent-only stuff is just sugar.
55
+
56
+ ---
57
+
36
58
  ## Why this exists
37
59
 
38
60
  OmniFocus is a powerful GTD tool, but it's an island. Your tasks sit there while you context-switch between your AI assistant and your task manager, manually copy-pasting notes, updating projects, and trying to keep everything in sync with your actual work.
@@ -614,20 +636,41 @@ Tools are organized by domain — tasks, projects, tags, folders, perspectives,
614
636
 
615
637
  ## Resources
616
638
 
617
- Ten MCP resources are registered under the `omnifocus://` scheme. Resources are read-only, URI-addressable, and enumerable via `resources/list`.
639
+ The server registers resources under the `omnifocus://` scheme. Resources are read-only, URI-addressable, and enumerable via `resources/list`. Templated URIs follow [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570) and accept the listed parameters as query strings or path segments.
640
+
641
+ **Static URIs** — read with no parameters:
618
642
 
619
643
  | URI | Returns |
620
644
  |---|---|
621
- | `omnifocus://capabilities` | Server capabilities: OF version, edition, transport status, feature flags |
622
- | `omnifocus://snapshot` | Five-count orientation object: inbox, flagged, overdue, dueToday, projectsDueForReview |
645
+ | `omnifocus://capabilities` | Server capabilities: OF version, edition, transport status, feature flags, calendar-bridge availability |
646
+ | `omnifocus://snapshot` | Orientation counts: inbox, flagged, overdue, dueToday, reviewDue, syncStatus |
623
647
  | `omnifocus://inbox` | Inbox tasks as `Task[]` |
624
- | `omnifocus://forecast/today` | Today's forecast grouped by overdue / due today / due later / inbox |
648
+ | `omnifocus://tasks/inbox` | Inbox tasks (alias of `omnifocus://inbox`) |
649
+ | `omnifocus://forecast/today` | Today's forecast grouped by overdue / dueToday / deferredToday / flagged |
625
650
  | `omnifocus://overdue` | All overdue tasks sorted by dueDate ASC |
626
651
  | `omnifocus://flagged` | All flagged available tasks |
627
652
  | `omnifocus://review-due` | Projects with nextReviewDate ≤ today |
628
- | `omnifocus://project/{id}` | Single project + full task tree |
629
- | `omnifocus://tag/{id}` | Single tag + its tasks |
630
- | `omnifocus://perspective/{id}` | Perspective evaluation result (same shape as `perspective_evaluate`) |
653
+ | `omnifocus://intents` | User-phrase tool-sequence map: a small set of human-meaningful verbs that compose the full tool surface |
654
+ | `omnifocus://stats` | Database-wide rollup: counts by project, tag, completion state |
655
+ | `omnifocus://taxonomy-audit` | Structural audit inconsistent tag/folder usage, orphans, drift signals |
656
+ | `omnifocus://waiting-on` | Every task carrying a `waiting-on` fence, sorted by daysOverdue DESC |
657
+
658
+ **Templated URIs** — accept parameters:
659
+
660
+ | URI Template | Parameters | Returns |
661
+ |---|---|---|
662
+ | `omnifocus://project/{id}` | `id` | Single project + full task tree |
663
+ | `omnifocus://tag/{id}` | `id` | Single tag + its active tasks |
664
+ | `omnifocus://perspective/{id}` | `id` | Perspective evaluation result (same shape as `perspective_evaluate`); Pro only |
665
+ | `omnifocus://tasks/project/{projectId}` | `projectId` | Active tasks under a project |
666
+ | `omnifocus://tasks/tag/{tagId}` | `tagId` | Active tasks carrying a tag |
667
+ | `omnifocus://recent-activity{?hours}` | `hours` (default: 24) | Tasks completed/dropped/created in the last N hours |
668
+ | `omnifocus://retrospective{?from,to}` | `from`, `to` (ISO-8601) | Closed-task aggregation for a date range — weekly review fuel |
669
+ | `omnifocus://velocity{?weeks}` | `weeks` (default: 4) | Per-week throughput: completed counts, completion rate trend |
670
+ | `omnifocus://burndown/{projectId}` | `projectId` | Per-project burndown vs naive linear ideal; needs project dueDate |
671
+ | `omnifocus://project-health{?staleDays}` | `staleDays` (default: 14) | Triage list: stalled projects, no-activity, review-overdue |
672
+ | `omnifocus://calendar{?from,to}` | `from`, `to` (ISO-8601, defaults to today local) | macOS Calendar events from EventKit; needs Calendar TCC grant |
673
+ | `omnifocus://agenda{?date}` | `date` (ISO-8601, defaults to today local) | Merged daily timeline: calendar events + OF forecast, kind-tagged |
631
674
 
632
675
  ---
633
676
 
@@ -902,6 +945,7 @@ Writes are saved locally and show up immediately in subsequent tool calls. Chang
902
945
  | [0013](./docs/adr/0013-tool-response-envelope.md) | Uniform response envelope |
903
946
  | [0014](./docs/adr/0014-e2e-harness-strategy.md) | In-memory adapter switch for E2E |
904
947
  | [0015](./docs/adr/0015-nl-excellence-response-envelope.md) | NL-excellence envelope: clarification, hints, summary |
948
+ | [0016](./docs/adr/0016-webhook-delivery.md) | Webhook delivery for OmniFocus state changes |
905
949
  | [0017](./docs/adr/0017-mutation-testing-release-gate.md) | Stryker mutation testing as release-time hard gate |
906
950
  | [0018](./docs/adr/0018-calendar-bridge-eventkit-only.md) | Calendar bridge — EventKit-only via Swift-binary subprocess |
907
951