v1.5.0
Changelog
This file starts at 1.5.0. For anything earlier, git log is the record — the
commit messages in this repository are written as prose with the reasoning and
the measurements in them, and they are better than a summary of themselves would
be.
Numbers here are measured, not estimated. Where a figure came off the author's
own store it says what it counted; the store itself is nobody's business but its
owner's, so counts and timings travel and subject matter does not.
1.5.0 — unreleased
Twenty-three commits since 1.4.0. Two days of driving every operation against the
real applications instead of against stubs, which is most of the story: the tools
that nobody had ever run were the tools that were broken, and every one of them
reported success while being wrong.
Operations: 42 → 65. Checks: 268 → 1219.
The theme, if there is one, is that a wrong answer used to be indistinguishable
from a right one. A read cut at its limit printed like a complete read. A failed
MCP call returned isError: False. A search that could not run returned an empty
list and exit 0. Most of what follows is that class of defect, found by running
the thing and reading what came out.
If you parse --json, read the first two items below before upgrading.
Everything else is additive or a fix.
Breaking changes
1. --json now emits an envelope, not a bare array
Every listing command used to print a bare JSON array. It now prints an object
with the rows under results and the truth about the read beside them:
Migration: jq '.[]' becomes jq '.results[]'. That is the whole change for
most callers.
This is the fix for the worst remaining case of a partial answer looking like a
whole one. TruncatedList is a list subclass, so json.dumps saw a list and
silently dropped every attribute on it — the truncated rows went out with nothing
saying they were truncated, to the callers most likely to act on a wrong count
without a human ever looking at it.
The row key is results on every command on every tool, rather than the
noun. messages read previously filed its rows under messages and
mail search-bodies under emails; following that convention would have meant
seven different row keys and a caller needing to know which tool it had called
before it could parse the reply. .results[] works everywhere.
Three details worth knowing:
- The envelope is decided by the payload's own Python type at one choke point, so
rows are wrapped and a single object is not. 21 commands carry an envelope;
mail read,calendar coverage,notes viewand every write return their
object unwrapped, as before. totalmay benull. That is a third state meaning not counted, and it is
deliberate — an AppleScript fallback that asks for N rows and gets N back
genuinely cannot say how many there were, and guessinglen(results)there
would manufacture exactly the false completeness this release exists to remove.mail search-bodieshas a fourth state: when the scan is cut by its own budget,
totalisnullandmore_hintswitches toscan_limit=-1, becausematches
counts hits only among the messages the scan reached and raisinglimitpast an
early-stopped scan buys nothing.
2. On note records, truncated is renamed content_truncated
On a note, the row-level truncated meant this note's text was cut at the
character cap. The envelope's truncated means the list was cut at the row
limit. Those are different facts, and on notes view --json the note is the
payload — so the row flag sat at exactly an envelope's depth meaning the opposite
thing.
Mail records have always spelled it content_truncated; notes was the odd one
out.
Migration: if you read truncated off a note record, read content_truncated.
3. notes create no longer creates a folder named Claude
create_note carried DEFAULT_FOLDER = "Claude" and created that folder when it
was absent. A user who renamed their folder found that the next plain
notes create brought a fresh Claude into existence and filed there.
Two faults in one: a vendor's name planted in somebody else's Notes app, and a
folder created as a side effect of an operation whose job is to create a
note. It now files into the default folder of the default account — a real
property in Notes' scripting definition, which resolved on both account types
tested — and refuses, naming the real folders, when it cannot resolve one.
No path in the package creates a folder any more, which is what makes the side
effect idempotent. The result carries folder, account and folder_created.
This changes where your notes land if you relied on the old behaviour. If you
want the old folder, name it: notes create --folder ....
4. Eleven flag abbreviations that used to resolve now exit 2
A consequence of the 42 new alias spellings below, and reported rather than
hidden. argparse resolves an abbreviation by counting option strings, not
actions, so once --from gained the alias --from-address, maps directions --fro became "ambiguous — could match --from, --from-address": two names for
one flag. Deduplicating by action fixed 8 of the 19 affected abbreviations; the
remaining 11 now exit 2 naming both candidates.
Full spellings are unaffected, and an exit 2 naming the ambiguity is never a
silently wrong flag. Measured across all 66 CLI leaves against the previous
parser rather than estimated.
A partial answer no longer looks like a complete one
The headline defect, on all seven tools and all three surfaces:
$ pyapple calendar list --from-date 2022-01-01 --to-date 2026-12-31
10 events:There were 3,631 events in that window. The same window with --limit -1
returns all 3,631 in 0.40s, so the data was there and only the report was wrong.
A tester read that output, concluded there were ten events, and filed the number
as a finding — the tool taught them something false and they believed it, because
nothing in the output suggested otherwise.
$ pyapple calendar list --from-date 2022-01-01 --to-date 2026-12-31
showing 10 of 3631 events (--limit -1 for all):Every read operation in all seven tools was audited, not just the one that was
reported. The count reaches three surfaces rather than two: CLI text, --json
(above), and MCP text — MCP renders text and nothing else, so total and
truncated living on a return value are facts an MCP caller never sees.
The same rule the exit statuses already carried, one step along: a failed read
must never look like an empty one, and a partial read must never look like a
complete one.
--limit -1 returned almost nothing — three separate bugs
--limit -1 is documented package-wide as "all", and it is what the new
truncation notice tells people to reach for. It was the most truncated answer in
the package in three places:
messages search --limit -1returned exactly one message. The candidate
window becamemax(-5, 100)and the stop conditionlen(found) >= -1fired on
the first hit. Now 17,109 messages in 102s — the price of decoding the message
blobs, paid only when asked for by name.notes list --limit -1returned one note where--limit 500returned 58.
The same>= limitstop condition, in the one module that was excluded from
the first truncation audit because it belonged to a concurrent workstream at
the time. The audit covered six modules, skipped the seventh, and every check
that ran, passed.maps search --limit -1returned one place, from amax(1, int(limit)).
Found by grepping the remaining limit arithmetic rather than by a report. A full
sweep found no fourth instance.
Also fixed: messages conversations --limit -1 silently dropped the last
conversation to a stray [:-1] slice.
MCP: isError now means what it says
A failed tools/call used to return isError: False with the reason under
content. A client branching on isError — which is what the field is for —
read a refusal as a success. Reproduced on three operations across three tools.
Every tool ended in except Exception: return f"Error accessing X: {e}", and
returning a string is returning a successful call whose text describes a
failure. The CLI had five exit statuses precisely so that a failed read could not
look like an empty one; MCP had one status for everything.
The classification is now mirrored rather than duplicated. A new
pyapple_mcp/outcomes.py holds the statuses and the single status_of() ladder;
the CLI's seven-clause except ladder collapses to one clause consulting it, and
the server's decorator consults the same function. The two front ends cannot
drift on what counts as a failure. Errors leave as fastmcp.exceptions.ToolError,
which is the one route FastMCP 3.0.0 documents as preserving the message text —
checked against the installed version and confirmed in the raw wire bytes.
The inverse mistake is the one worth guarding against, and it is in the table:
an empty result is a success. A search that matched nothing, a mailbox with no
unread, a truncated read — all exit 0 and all stay isError: False.
mail read on a missing id is an error and notes view on a missing title is
not, because that is what the CLI already did; mirrored rather than harmonised,
since harmonising would be a behaviour change wearing a consistency argument.
Two faults found while doing it: three contacts refusals returned their message
unguarded, so an ambiguous name reported success on both front ends; and
maps search had a nested try/except swallowing the failure its own branch
raised.
Notes
-
No note in a subfolder could be edited.
folders of accountreturns every
folder in an account, nested ones included — undocumented — and the walk seeded
from the account and then descended, visiting each nested folder twice. So
search returned two identical records for such a note, and every write refuses
on more than one match. Appending, renaming, editing and deleting were all
impossible, and the refusal told the caller the note was ambiguous. Nothing they
could do would clear an ambiguity that did not exist. -
notes folder-deleteis new, and its contract is unusual because what it
does is unusual. It refuses four things, each measured rather than assumed:- A folder holding notes, unless you pass their exact count, re-checked
against the live store inside the delete script. A boolean can be set by a
model that never read the notes; a number cannot be guessed, so supplying one
is evidence of having looked. - Any folder with subfolders, with no override. Deleting a parent does not
delete its children: the child and its note survive and the child's container
still answers the dead parent's id, raising-1700. That is a broken store
rather than a loss anyone can authorise. - The trash, and any account's default folder — read off
default folder,
never matched by name. - The notes it deletes are destroyed, not moved to Recently Deleted.
Verified: a folder with 2 notes deleted, trash count 3 before and 3 after,
and a search that does reach the trash matched 0 of the two bodies. The
refusal says so and namesnotes deleteas the recoverable route.
It also reports
may_return: True, because a deleted folder came back.
Reproduced four times: deleted, absent at 11s and 31s, back at 51s with both
notes and the same folder id, no restart. Three other deletes stayed gone, so it
is intermittent. It happens on both account types tested and reliably on
neither — in one trial one account's folder was back inside 15 seconds while the
other's stayed gone five minutes; in an identical second trial neither returned.
The caveat is therefore universal rather than naming a provider, which would
have pointed at the slower of the two. - A folder holding notes, unless you pass their exact count, re-checked
-
folders,folderCreateandfolderRenameare new. Renaming a folder
persists, unlike the note case, verified from fresh processes. -
notes createrefuses an unknown folder rather than making one out of a typo. -
notes searchhad no--limiton either front end while its own hint told
callers to pass one. It has one now. -
Every read in the module carries a true total.
notes list --limit 5used to
reporttotal: 5, truncated: falseagainst 58 notes — not a missing count but a
false claim of completeness. -
A folder dropped from a listing because its child was unresolvable used to be
visible only in a log line, and is now counted.
Calendar
- A window longer than four years silently lost the newest years. The cap is
1,461 days — four calendar years — from the window start, found by binary
search on the end date rather than taken from documentation: a window opened in
one year returned the same 3,086 events whether it asked for 48 months, 54
months or 120. Reads are now chunked into 1,400-day segments, 61 days inside
the cap. On the store it was measured against: 3,086 events in 0.33s before,
3,631 in 0.40s after — 18% more data for about 15% more time, because two
narrow queries are nearly as cheap as one wide one. Past 64 segments (roughly
245 years) it raises rather than returning a short list. - The obvious fix would have introduced a worse bug. Deduplicating across
segment boundaries wants a key, and the obvious key is the record'sid. Over
3,086 occurrences, 47 shared a bare identifier with another event while
reporting no recurrence rule — detached occurrences, which answer
hasRecurrenceRules()false but keep the series' identifier. An id-keyed merge
would have dropped all 47. Worse, it meant duplicate ids were already being
emitted for those 47, and every write resolves an ambiguous id to the first
occurrence. Ambiguous ids: 47 under the old rule, 0 under the new one. calendar coverageis new: per calendar, occurrences, the distinct series
behind them, all-day count, real earliest and latest, writability, and how many
events sit outside the scanned window. Empty calendars are listed, because a
report built from what came back would omit them and "absent" reads as "does not
exist". It now hands back the extent its probes actually reached rather than a
suggestion, andreach_exhaustiveis false whenever a probe found anything,
because a non-empty probe is never evidence of an edge.- Recurrence, alarms and attendees.
recurrence/recurrence_interval/
recurrence_days/recurrence_week/recurrence_count/recurrence_until
are explicit fields rather than a natural-language string, because parsing a
sentence puts the misreading at the point where it is indistinguishable from a
correct reading and lands as a series on the wrong days in somebody's diary.
What it cannot build it refuses by name, including the dangerous shape — a
recurrence field with no frequency, which would otherwise create a one-off and
report success.alarmsis its own operation;alarm_minutes_beforeis
unsigned, because the underlying API takes a negative interval and that sign is
exactly what a caller gets backwards.include_attendeesis opt-in: it costs
about 1.7x on a read and sprays addresses into transcripts that did not ask for
them.
has_attendeesis always on and costs 0.002s. series_idgroups occurrences into series. A one-off is a series of one, so
grouping needs no special case.calendar movenever existed —AttributeErroron every call, while three
modules declared it.- Deleting a repeating event removed the wrong occurrence. Every occurrence
shares one identifier and the loop deleted the first it walked past; asked for
the 4th of a 5-occurrence series, it removed the 1st. - "No such event" took 60.06s and returned
success: Falseon what was
actually a timeout. Now 0.002s. - Timezone offsets were discarded on create: a time given as
+00:00was stored
as that clock time in local time, up to 13 hours out. - A single named day returned nothing, because
from == tois a zero-width window. calendar freeis new and returns gaps rather than events. It ignores all-day
entries deliberately: on the machine it was built against, 211 of the next 365
days carry one, so counting them busy books every day solid.
--mailbox INBOXanswered about mail nobody has in an inbox. Where a
provider stores each message once and records mailbox membership in a separate
labels table, the INBOX mailboxes hold zero rows. Measured:unread --mailbox INBOXreported 2 unread to a user with 6. The predicate follows labels
now and reports which one matched.--accountwas accepted, documented, and dropped, so scoping to one account
returned all of them. Mailbox URLs are keyed by the account's uuid and the
name→uuid resolution was never done. An unknown account now raises rather than
silently widening.- 6.5% of downloaded bodies read as empty — a single-part HTML message hit a
branch handling only plain text. 26 of 400 sampled, 26 of 26 empty, which is
indistinguishable from a body that was never downloaded. The 500 newest messages
went from 447 bodies to 500. - HTML bodies were 55% layout: stylesheet contents survived tag-stripping as
prose, so against the character cap you paid for indentation and lost the end of
the message. mail search-bodiescrashed on every invocation — an argument-count
mismatch that raised after doing all the work. Not "returns empty": it never ran
a single time for anybody.- 13 of 36 mailboxes were unmatchable because mailbox URLs are percent-encoded
and--mailboxwas not. Both spellings match now. - Every mail read moved off Apple Events —
--accountused to resolve through
atell application "Mail", which starts Mail, for what is otherwise a local
SQLite read. Proven against a runner that raises on any Apple Event.
(mailboxesandunread --mark-readstill drive Mail; the earlier claim that
every read had moved was too broad and is corrected.) mail readprinted no body — the renderer readcontentwhere the handler
returnsbody: 199 bytes of output against a 1,061-character message. And
mail readon a nonexistent id exited 0 printingFrom: None, because it
tested a dict for truthiness when absence is signalled by afoundkey.- New:
readMessage(one message whole by id, 3ms warm),coverage(what
each account can actually answer about),searchBodies(bodies are not in
the index, so this can only ever be partial and the result says by how much),
and mailbox-level coverage, which answers whether a mailbox reporting no
unread is empty or simply unindexed. - An empty result now says why it is empty. "No results" and "nothing to
search" used to print identically. A new scope description answers four facts in
priority order — the named mailbox is not in the index,--sincereaches
further back than this Mac holds, this scope has nothing indexed at all,
otherwise what the scope does hold. It speaks on any empty result, is silent on
a successful one (verified: 439 bytes to stdout, zero to stderr), goes to
stderr prefixednote:so--jsonis untouched, and is pinned under 300
characters because a note that long gets skipped. - Coverage reporting corrected a measurement of its own: an aggregate reading
"32% of the store" was the mean of one account at 0% of 31,143 messages and two
at 100%. A number taken across the thing that works, hiding a zero in the thing
the user has — which is the whole reason per-account coverage exists. - A drafted reply is now its own operation,
replyDraft, in thedraftclass.
Previously the gate sawreplywhatever flags followed it, somail reply --draftcost thesendgrant while two docstrings claimed otherwise. Both CLI
spellings reach it: the newmail reply-draft, andmail reply --draft, which
shipped in 1.4.0 and is remapped rather than removed.
Messages
- A page of a conversation was indistinguishable from a whole one.
messages readreturned the most recent N with nothing to say how much was
behind them, so twenty messages out of eleven thousand rendered exactly like a
complete thread of twenty. Reads now carrytotal,truncatedandnext_until,
and takeuntil. untilrather than an offset, deliberately: an offset is counted from the
end of a live conversation, so a message arriving mid-walk shifts every later
page and the caller silently repeats and skips. The cursor comes from the raw
nanosecond column rather than the rendered second-resolution timestamp, because
people send four things inside one second. Verified on a real store: two pages
of twenty overlapped by exactly one message on each of three threads.totalis counted with the same WHERE clause as the page, not a looser one:
the page filter drops tapbacks, and a count including them would send a caller
hunting for messages that were never going to be shown. 3.0–4.7ms on the three
busiest threads tested, against reads of 40–400ms.- New: message body search, tapbacks folded onto the message they were aimed at
rather than rendered as literal transcript lines, delivery state, attachment
names, andsend --chat-id— the only way to reach a group, since sending to
one participant's number starts a separate one-to-one and nothing in the result
says the group never saw it. schedulecannot work and refuses, with a check pinning that it never
quietly sends immediately instead. The scripting definition declares three
commands and none takes a date. It stays on the surface rather than being
removed, because a model asked to send at 7am with no such operation is likely
to reach forsend.
Contacts
- Contacts writes stopped launching Contacts.app. The three writes were still
Apple Events, and an Apple Event to Contacts launches it — including on calls
that were about to be refused for a bad argument, because the reachability check
was itself the trigger. The permission shrinks rather than trading: the
framework path is gated by the Contacts privacy grant the reads already hold,
where Apple Events need a separate "control Contacts" Automation grant. Wall
clock: add 0.86–1.12s → 0.06–0.77s, addTo 0.69–0.80s → 0.19–0.28s, delete
0.79–1.21s → 0.27–0.37s. contacts renameis new — correcting or completing a name on an existing
card. A part left out is left alone, and clearing a name is not offered: over
MCP "not given" and "set to empty" are the same value, and guessing wrong wipes
a field nobody asked to wipe.- 20 of 179 real cards refuse every framework write with Cocoa error 134092 —
11.2%, and stable per card rather than intermittent (5 of 5 attempts fail on an
affected card, 0 of 5 on each control). Nothing the API exposes separates the two
groups, so it cannot be pre-flighted and retrying is pointless. The failure is
total, never partial: every affected card was byte-identical afterwards.
Nothing ever claimed a write that did not happen — what was wrong was the
message, which now names the card, the cause, and the fact that the Contacts app
can still edit it. - A no-op save reports success. Writing back a value already stored returns
true and writes nothing, which is why the first two attempts to measure the
above reported 0 failures across 20 cards. Any verification built on a no-op
proves nothing — and it is whyrenamerefuses a change that would not change
anything. - An ambiguous name now names the candidates instead of saying "3 contacts
match, please be more specific", which tells a caller it failed and leaves them
guessing a longer string. Names and not ids, deliberately: no contacts operation
accepts an id, so printing one would be a handle to nothing. contacts searchby name called the phone-number lookup, so "what is their
email address?" was unanswerable through the MCP tool while the address sat on
the card.
Maps
- Maps was fiction. Maps.app ships no scripting definition at all, so all
seven operations were written against a vocabulary that does not exist. Three
were AppleScript syntax errors that had never executed a line for anybody; the
other four launched the app, did nothing, and returnedsuccess: True. searchanddirectionsnow go through MapKit: 2.68s → 0.75s and 2.23s →
1.36s, returning addresses, coordinates, turn-by-turn steps and arrival times
where they previously returned an apology.- A threading fact that would have made this silently broken: the search API may
be started anywhere but delivers on the main queue, and FastMCP runs a sync
tool on a worker thread — so a naive in-process implementation hangs to its
timeout and reports "no results", which is the failed-read-looks-empty bug one
level down. Hence the helper subprocess, and a check asserting a worker thread
cannot pump the main runloop. - Favourites and Guides are permanently unimplementable — no API, and nothing
readable on disk. Those four operations report failure and hand back a durable
maps.apple.comlink that opens on any Apple device. maps searchprintedFound 5 place(s)counting the survivors of filtering
rather than the matches.
Reminders
completeanddeletedid not exist, which is half of what anybody asks of
a reminders app, and neither did enumerating lists — so "add these to my
shopping list" could not find the list.createwas broken three ways at once: it reported failure while having
created the reminder, it dropped the due date entirely, and the line meant to
apply the date would have set it to now.- Due dates rendered in UTC, so an evening reminder printed as the following
day and an assistant asked "what is due today" answered about tomorrow. opentook 15.2s, and 15.3s to report no match. Now 2.6s, and it actually
reveals the reminder rather than merely activating the app while reporting
"Found and opened".- A text query matching more than one reminder is refused with the candidates
named rather than resolved, because completing the wrong reminder makes it
vanish with nothing to explain why, and "first match wins" makes that a coin
flip on iteration order. reminders list --list-name <typo>exited 3, meaning "the store could not
be read", and sent the reader to Privacy & Security — while the message
disproved itself on its face by naming the real lists. Now exit 2.
The command line
-
42 alias flag spellings across 31 leaves, so that a reasonable wrong guess
works. Aliases rather than renames: argparse takes several strings per action,
so no existing caller breaks and--helpstill leads with the documented
spelling. Every one was driven through the real entry point against recording
handlers and compared to the canonical spelling — identical call, exit code and
stdout, 0 mismatches. The cost is item 4 under Breaking changes, and it is
reported rather than buried. -
Handler messages now spell flags on the CLI and parameters over MCP. A
handler built a message naming its own Python parameters and both front ends
printed it verbatim, so shell users were told to type things that are not
typable —(pass limit=25 or more),pass delete_notes=1,pass account= or mailbox=. The parameter is now marked in the handler with two control
characters and rendered by whichever front end knows how its caller writes.
One fact, built once where the fact is known. Found by an AST walk over every
string literal in the package rather than by grep: 21 sites across five modules. -
The logger was a third exit nobody had rendered, so
pyapple mail unread --mark-readput raw marker bytes on stderr. The marker design assumed two
exits and there are three, across 115 call sites. Fixing it needed the filter
attached per logger rather than on the package root — an ancestor logger's
filters never run, only its handlers — and it formats tracebacks and
interpolated arguments too, since a message-only filter misses both. -
Three ambiguity refusals in
contactsand every notes write used to give a
count and nothing to act on. Onlyremindersgave ids. -
A malformed
PYAPPLE_PERMSor--permsnow exits 2 with a usage line
instead of 1 with a traceback. Failing closed was always right; the status
was not — exit 1 means the operation ran and failed, and a policy that will not
parse means it never started. The policy is parsed at import, so the error
escaped from animportstatement: past the boundarytryon the CLI, and
pastserver.py's module import over MCP, where the client saw only "server
disconnected". The message now also names which input was wrong, which the
parse error alone never said, and the MCP failure names the config'senv
block. -
Two control characters cannot be stored, and the package now says when it
removed them.U+001EandU+001Fare the markers above, so caller text
containing them could otherwise forge a flag into a rendered message. They were
already being stripped on the AppleScript routes — but only as a side effect of
the escaper having no literal form for a C0 control character, and the
EventKit, Contacts and MapKit writes had no scrub at all: a reminder name
carrying the markers was stored raw and rendered asCreated reminder 'shop --account now', a flag this tool has never offered. The scrub is now at the two
front-end boundaries — the argparse namespace on the CLI, the keyword arguments
over MCP — so it covers every write path and every read argument by
construction rather than per handler. Silently altering what was stored would
be the same fault as the rest of this release, so it is reported: anote:on
stderr,removed_control_charactersunder--json, and a sentence appended
over MCP. -
pyapple permsnow names the six tools an unscoped policy leaves untouched.
Two testers in a row expectedPYAPPLE_PERMS=mail=readto disarm the other six
tools; it does not, deliberately — a user who writesmail=readhas said
something about mail and not about their calendar. The design stands and the
documentation was the problem:No '*' entry, so the 6 tools the policy does not name keep every operation: contacts, notes, messages, reminders, calendar, maps. Add '*=read' to narrow them, or '*=none' to remove them entirely. -
notes search --jsonused to print an error to stderr and[]to stdout
and exit 0. Underneath was a second bug: a search matching nothing returns
the empty string, which is falsy, so every empty search took the failure path
and reported an error that did not exist.
Performance
Calendar reads got faster by building fewer records, not by reading less:
list 5yr limit 10 387.1ms -> 217.4ms -44%
list 1yr limit 10 57.3ms -> 32.1ms -44%
search 5yr limit 10 390.7ms -> 242.6ms -38%
search 1yr limit 10 58.3ms -> 36.1ms -38%
list 5yr limit -1 387.5ms -> 385.6ms -0%
A read returning ten rows used to build a full record for every occurrence in the
window first — profiled over five years, that was 438ms of a 1,289ms read, all
but ten records of it thrown away. The count and the slice now happen on the raw
occurrences and records are built only for the rows that survive. Search matches
through three property reads instead of about fifteen: 25.4ms against 160.9ms
over 3,631 occurrences.
limit=-1 is unchanged and should be — every record it builds is one it returns.
The remaining time is 89% fetch and irreducible.
Equivalence was measured rather than assumed: 62 behavioural keys captured from a
real store before and after, all 62 identical, including full 3,631-record dumps
and orderings across roughly 30 daylight-saving transitions each way.
One reported regression turned out not to exist. Calendar honesty was reported as
costing 58ms → 102ms on a one-year window; the relevant function was
byte-identical across the two commits and best-of-5 on both trees gives 57.2ms
and 57.3ms. The original measurement had no warm-up, so a best-of-3 still carried
a cold framework. Recorded because the wrong number is what commissioned the work
— which was worth doing anyway.
Documentation
- README and the skill document described a package that had moved under them.
Operation counts of 58 and 60, both wrong, now 65 verified off the policy table.
Ten false claims corrected, three of which misled about capability rather than
merely being stale. CLAUDE.md, which ships in the sdist, claimed AppleScript was "the primary
method for application automation". It is now a three-route table: native
frameworks for contacts, reminders, calendar and maps, where the app never
launches and no Automation grant is needed; read-only SQLite for mail and
messages reads; Apple Events for all of notes, mail writes andmessages send.- The skill document's "known bugs" section listed three, all three already
fixed, so a reader worked around problems that no longer existed while the
genuinely sharp edge in that build was not listed. It now says it is empty and
says why an unpruned list is worse than none.
Known limitations, reported rather than fixed
mail --limit 0returns nothing, where notes, messages, calendar and maps
return everything. Mail spells unbounded as a large sentinel rather than testing
for-1.- A failed MCP tool call is noisy on the server's stderr. FastMCP calls
logger.exceptionfor every tool error, so a failure now writes about 4.4KB and
54 extra lines of traceback — including for an ordinary missing parameter, which
previously never raised. 1,812 bytes on a successful call against 6,186 on a
usage error. There is no hook, and quieting a third-party logger would also hide
real faults, so it is left alone rather than reconfigured unilaterally. - Every
mail sendleaves a copy in the sender's Drafts. Mail autosaves any
outgoing message it can attribute to an account — measured by building a message
that was never saved and never sent, which appeared in Drafts within 8s and
stayed. The copy appears asynchronously, so an in-script cleanup runs too early
and a later one would mean this package deleting from your Drafts by subject
match. - Notes' HTML serialiser and its HTML parser do not agree, so the body it
hands you is not a body it will take back: images and attachments are dropped,
link URLs are lost, headings become bold spans. Every edit is a read-modify-write
because Notes offers nothing else, so a note carrying any of that is refused.
On the store this was measured against, 0 of 56 existing notes pass the gate.
That is the honest number, and it beats an editor that works on plain notes and
eats the rest. Notes this tool creates always pass. - An
.emlxbody is filed under its account, not its mailbox, so body search
is account-level and cannot be otherwise. - A mail message id is good now and not later. Two ids printed by one command
were absent from the database entirely about sixty seconds later — not flagged
deleted, gone, because Mail had reindexed. Do not retry a stale id. - A Notes deletion verified by an immediate listing is not verified, per
may_returnabove. This applies tonotes deleteas well asfolder-delete.
Verification
- 1219 checks, up from 268 at 1.4.0. They run without a mailbox, a TCC grant
or a network, and they do not read the machine they run on: the timezone in the
reminders fixtures is pinned deliberately so an offset assertion cannot pass by
accident of geography. - Every check added in this release was made to fail before being trusted —
delete the line it covers, confirm red, restore. Over 500 mutations across the
release, by the commit messages' own counts. The passes that are worth reading about are the ones where a
mutation came back green, because every one of those was a check that was
not watching what it claimed to: a guard tested against a three-card fixture
that refused as ambiguous rather than for the reason under test; checks
parametrised over the very table they were meant to protect, so deleting an
entry deleted its own check; a check asserting onlytruncated is False, which
stays green whentotalis never computed at all. - The mutation harness itself was found to be lying. Mutations rewrite the same
file several times inside one second, and CPython validates a cached.pyc
against the source mtime in whole seconds — so a run could import bytecode
compiled from the previous mutation. One entry was deterministically red alone
and deterministically green when another ran first. A false green under-reports
coverage; a false red certifies that a check catches a bug when it does not,
which is the only claim the harness makes. Both were happening.
PYTHONDONTWRITEBYTECODE=1on the child makes it deterministic.