aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md111
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md113
-rw-r--r--docs/superpowers/specs/2026-08-29-sync-status-file-design.md177
3 files changed, 357 insertions, 44 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index b3cda60..d788573 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -9459,3 +9459,114 @@ non-held branch, which fails with the exact text the user reported.
The string is translated, since a user-facing string that misses the Italian
translation ships as English inside an otherwise Italian UI: `lupdate` found it
with no context warnings and `lrelease` reports 552 finished, 0 unfinished.
+
+## 174. An external sync's outcome can only be inferred, and never names what it carried
+
+**Done 2026-08-29**, unreleased. Filed as "an external `notmuch new` reaches the index without the pending count noticing"; the title changed because the premise did, see below.
+
+**Observed (user, from the notes):** "the statusbar still reads that an
+external notmuch run can clear the pending edits without the bar noticing.
+Since we own mailsync and the whole process, we should fix that."
+
+**Cause (verified in the code, 2026-08-28).** Item 54 built exactly one
+external path and it is narrower than the note assumes. `SyncMonitor` watches
+the inode of `/tmp/mbsync.lock` in `/proc/locks`
+(`syncmonitor.cpp:64`), which is the file `assets/mailsync.sh` flocks, and
+`onExternalSyncStateChanged()` clears `m_pendingTagEdits` only when
+`MailSync::lastRunOutcome()` reads an OK from that script's log
+(`mainwindow.cpp:4812-4840`).
+
+A `notmuch new` that is not that script takes notmuch's own write lock inside
+the Xapian directory and never touches `/tmp/mbsync.lock`. Nothing observes
+it: no state change, no log line to read an outcome from, so the count keeps
+reporting work that has already shipped and the exit prompt offers to sync for
+it. This is item 54's symptom surviving through the one route item 54 did not
+cover.
+
+**Approach (not decided).** The note names the lever: the process is ours, so
+the honest fix is to stop inferring a sync from a lock file and observe the
+INDEX instead. `notmuch_database_get_revision()` gives a monotonic uuid plus
+revision that moves whenever anything is committed, whoever committed it; the
+worker already holds a handle and reopens it. Watching that would cover the
+script, a hand run and a cron entry alike, and would make the outcome question
+moot: a revision that moved is evidence the write landed, where a log line is
+a report about it.
+
+**Constraints.**
+- Clearing the count on an observed revision bump is NOT sound on its own. A
+ revision moves for mail arriving as well as for our edits landing, so the
+ bump has to be read as "the index changed, re-check" rather than "our edits
+ are in". What settles it is the per-message check the pending map can
+ already do: every entry names a message and a tag.
+- `SyncMonitor` stays whatever this becomes. It answers a different question,
+ "is a sync running", which drives the spinner and the write hold, and a
+ revision counter cannot answer it.
+- Item 125 is open on the same monitor and should be read alongside: a
+ `mailsync.sh` that exits 75 leaves the spinner running for ever.
+
+### What was built, and why it is not what the entry above proposed
+
+**The premise was corrected before any code.** The note asks for an external
+`notmuch new` to clear the pending count. It must not. The count means
+"confirmed tag mutations not yet known to have reached the MAIL STORE"
+(`mainwindow.h`), which is the server: a tag edit is in notmuch the moment it is
+made, and what is outstanding is mbsync pushing the renamed Maildir files.
+`notmuch new` re-indexes local files and pushes nothing, so clearing on it would
+tell the user their work was safe to quit on while it was still local. That is
+item 28's defect in a new costume.
+
+The entry's own Approach, watching `notmuch_database_get_revision()`, was
+rejected for the same reason: a revision moves when mail ARRIVES as well as when
+edits land, and in neither case does it say anything about the server.
+
+**The user reframed the scope, and that is what shaped the fix.** The script was
+written for another system and adapted; it is now qtmaildir's only consumer, so
+it serves the application rather than the reverse. Where a gap is found here, the
+script is reshaped to bridge it. Two facts made this safe to act on: the crontab
+runs `mailsync.sh` and nothing else touches mail, and `~/bin/mailsync.sh` is a
+SYMLINK into this repo, so an edit is live on the next tick with no deploy step.
+
+**What was actually wrong** was the reporting channel, not the syncing. The
+application inferred a finished run from an inode in `/proc/locks` and from
+grepping the log for `RUN END ... status=OK`, which made a human-readable line
+into wire format and could not answer the question the count needs answered:
+which channels did this run carry? The local path has always narrowed its clear
+to the accounts it carried; the external path could not, and cleared everything,
+so an edit to an untouched account was reported as delivered.
+
+**Built:** `assets/mailsync.sh` writes `~/.local/state/qtmaildir/syncstatus.json`
+atomically at the end of every run including a skip; `MailSync::readStatus()`
+reads it; `MainWindow` prefers it over the log banner, narrows the clear by
+channel through `Account::syncChannel()`, and clears nothing on `skipped` or
+`failed`. The log banner and `lastRunOutcome()` stay as the fallback for a
+missing file, which is what a first run after upgrading looks like.
+
+Design: `specs/2026-08-29-sync-status-file-design.md`.
+
+**Item 125 is half closed by this.** A skipped run is now a terminal state the
+application can see, so the spinner has something definite to clear on. Whether
+`SyncMonitor` should ALSO time out an observation it never saw end is untouched
+and stays in that item.
+
+### Two bugs found while wiring it in, both worth keeping
+
+**A test read the developer's real sync state, twice.** A `[sync]` section
+naming only `log` leaves `syncStatus()` defaulting to the real
+`~/.local/state/qtmaildir/syncstatus.json`, so two existing tests asserting that
+a FAILED run leaves the count alone read the last real cron run, found `ok`, and
+cleared. Pinning only `status` has the mirror problem, and that is how the third
+one broke. This is the `/proc/locks` trap of item 61 in a new place, and the
+same answer applies: **a sync test must pin BOTH keys inside its own
+QTemporaryDir.** `noSyncTestReadsTheRealSyncState()` is the guard, modelled on
+`noTestCanSeeTheRealLockTable()`.
+
+**`Qt::ISODate` carries no milliseconds, and a staleness check on it is wrong by
+up to a second.** The status file is preferred only when it describes THIS run,
+compared against when the lock appeared, so a stale success cannot outrank a
+fresh failure. But the script writes `date -Iseconds`: measured in a standalone
+probe, an ISODate round trip of "now" comes back **329 ms behind**, so a fast
+sync's own file parsed as older than the lock and was judged stale. The fallback
+still worked, so the symptom would have been "the channel narrowing never
+happens" with nothing failing. One second of slack matches the precision the
+format actually carries, and cannot readmit a genuinely stale file when cron
+runs ten minutes apart.
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 8edc6bd..2cc3b5c 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -193,7 +193,7 @@ taking that too literally.
| 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s |
-| 125 | A skipped sync leaves the spinner running for ever | defect | S | open, 2026-08-20, found by hand. `mailsync.sh` exits 75 (EX_TEMPFAIL) when another run holds the lock; the indicator never clears, and a held edit waits for a completion that never comes |
+| 125 | A skipped sync leaves the spinner running for ever | defect | S | open, 2026-08-20, **half closed 2026-08-29 by item 174**: a skipped run now writes `state: skipped` to the status file, so the application can see it happened and clears the spinner without clearing the count. What remains is whether `SyncMonitor` should also time out an observation it never saw end, which covers a run that dies without writing anything at all |
| 126 | A link with `target="_blank"` does nothing when clicked | defect | S | **done** 2026-08-20, unreleased. `createWindow()` returns a relay page that receives the navigation, hands the URL to the browser and refuses. The URL cannot be read in `createWindow()` itself, which is why a relay rather than a lookup |
| 127 | A link's context menu offers four browser actions that cannot work | defect | XS | **done** 2026-08-20, unreleased. Three Open-in actions removed, `CopyLinkToClipboard` kept. Item 126 made them more dangerous rather than less: with a real `createWindow()` they would have started working |
@@ -250,13 +250,14 @@ taking that too literally.
| 176 | Undoing a thread-scoped action applies its inverse to messages it never changed | defect | S | **done 2026-08-28**, unreleased, on `thread-row-identity`. `NotmuchWorker::applyTags()` reads each message's tags before writing and reports only the ids whose tags actually MOVED; a `TagCommand` base carries that effective set for both `ThreadTagCommand` and `MessageTagCommand`, which had the same defect on a multi-row selection. `tagsApplied` does NOT fire on an empty effective list, since an empty change would push an undo entry whose inverse adds a tag no message ever carried, the same bug one step later. `sendThreadTagChange` gained `onlyMessageIds` so it keeps its thread-scoped REPAINT while restricting the WRITE: the card that changed on screen and the messages that changed on disk are different sets on purpose. **The spec's own plan said item 177 would make a thread undo honest and shrink this to the multi-row case; that was wrong and is corrected in the spec**, an undo inverts an EFFECT, not a scope |
| 177 | A thread row means both a message and a conversation, and neither consistently | design | L | **done 2026-08-28**, unreleased, on `thread-row-identity`, eleven commits. Spec: `specs/2026-08-28-thread-row-identity-design.md`. `ThreadListModel::isConversationRow()` is the single predicate and `scopeForSelection()` the single resolver, replacing the `scopeFor()`/`messageScopeFor()` pair that made the CALLER choose. A summary with `totalCount == 1` is unchanged. **Reverses items 108, 110 and 111**, and the user confirmed they are happy to lose the two-tier chips; the `*_thread` submenu and its five action names are deleted with an `### Upgrading` note. Item 112's hiding rule is reversed too: with the absolute entries gone, hiding the toggle on a mixed selection leaves no way to act, so it is a catch-all and the write direction moves with the label. Membership is the union, with two user decisions kept (never evict the current row; an asked-for write evicts at once, an automatic one defers) and one documented lag (a long thread's summary is not updated by a message write, so reading its last unread message waits for the next query). Dashboard from a `ThreadDigest` read by its own worker walk. Two traps found while building: a `QStackedWidget` takes the LARGEST minimum width of its pages and the hidden dashboard was raising the pane's minimum to 395px over MainWindow's 300px floor, caught by an existing resize test; and the pane now holds two `TagStrip`s, so both are named |
| 178 | Delete and Restore judge a conversation on one message | defect | XS | **done 2026-08-29**, unreleased, on `thread-row-identity`. `ThreadDigest` carries every message's path, collected by the walk it already makes, so the predicate tests the whole conversation. Known for the SINGLE selected conversation row the digest was requested for; any other selection falls back to the summary's one path, which is the pre-177 answer, deliberately left no worse rather than given a second differently-wrong rule. Section in the closed file |
-| 174 | An external `notmuch new` reaches the index without the pending count noticing | defect | S | open, 2026-08-28, from the notes. Item 54 cleared the count for a sync run by `mailsync.sh`, which is what `SyncMonitor` watches; a bare `notmuch new` (a hand run, or a cron entry that is not the script) takes notmuch's own write lock and touches `/tmp/mbsync.lock` not at all, so nothing observes it. The user's framing is the approach: we own `mailsync.sh` and the whole process |
+| 174 | An external sync's outcome can only be inferred, and never names what it carried | defect | S | **done 2026-08-29**, unreleased. The premise was corrected first: a bare `notmuch new` must NOT clear the count, since the edits are in the index but not on the server, and the item's own proposal to watch `notmuch_database_get_revision()` was rejected for that reason. `mailsync.sh` writes a JSON status file instead, naming the channels a run carried; the external path now narrows its clear the way the local one always has, and a `skipped` run clears nothing. Log fallback kept. Section in the closed file |
| 175 | The send countdown says Undo, and cannot be skipped | presentation | XS | open, 2026-08-28, from the notes. Two changes in one control: the button reads Abort, and a second button sends immediately rather than waiting the countdown out |
| 179 | Undo is one level deep in practice, and there is no Redo | workflow | ? | open, 2026-08-29, from the notes. The `QUndoStack` is real and multi-level; what is missing is a `redo` action (absent from `knownActions()`, never called) and an answer to the stack being CLEARED on every new query (`mainwindow.cpp:3458`), which is what makes a deep stack behave like a shallow one. The clear has a correct reason and cannot simply be removed. Redo re-applies a write to real mail, so item 176's rule binds it too |
| 180 | The repaint rules are discovered one hole at a time | maintenance | S-L | open, 2026-08-29, from the notes, and a QUESTION rather than a defect. Items 105, 107, 109, 110 and 170 are each one hole in the same surface, all found by hand. Three mechanisms (optimistic repaint, `syncViewMembership()`, revert) agree by documentation rather than by code. Cheapest answer is one invariant test, not a rewrite; the user decides which, and that decides the size |
| 181 | The thread dashboard does not follow a write to the conversation it shows | defect | XS | **done 2026-08-29**, unreleased, on `thread-row-identity`, from the notes. The dashboard draws a `ThreadDigest` built by the worker from the INDEX, which arrived only on selection, so a tag write moved the model and the card and left the pane reporting the count the conversation had when it was opened. Reachable from the dashboard's OWN Mark all read button. Re-requested from `onTagsApplied()`, where the write is confirmed: queued beside the write it races it and answers from the state before it, which is how the first fix passed review and failed the test. Section in the closed file |
| 182 | An edit made during a sync is announced twice and never says it is waiting | defect | XS | **done 2026-08-29**, unreleased, on `thread-row-identity`, found by hand. The hold branches set a deliberately NON-transient label; all three callers overwrote it a line later with the bare action, so the user was told the write had landed and then told again when it really did. `announceAction()` adds the wait to the action rather than replacing it, since that announcement is what stands in for the confirmation dialog this project rules out. Section in the closed file |
| 183 | `undoingAMarkReadRestoresOnlyWhatWasUnread` fails about 1 run in 9 under the full suite | testing | ? | open, 2026-08-29, measured. Item 176's regression test, which guards the undo that rewrote 44 messages of real mail. Nine runs on master: 4 standalone, 3 under `ctest -R mainwindow`, 3 under the FULL parallel suite, and the single failure was in the last group. Not a regression, the base commit behaves the same. Probably the same root cause as item 136 and worth solving with it |
+| 184 | New mail waits up to ten minutes, because sync is a fixed cron tick | workflow | ? | open, 2026-08-29, from the user: the 10 minute tick "has always bothered me", and it is already a compromise down from 30. Outgoing edits are immediate (`auto_sync_delay_ms`), so this is the INCOMING half only. Polling faster is not the answer; IMAP IDLE is, and it lives in a watcher that triggers `mailsync.sh`, NOT in qtmaildir, which does no network protocol work. Needs decisions first: which watcher, whether it packages on Slackware, and what the server supports. **Blocked on 174**, whose status file is the reporting channel this needs anyway |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1223,48 +1224,6 @@ rich-text composer with the original inside it, editable.
same want ("show me what I am writing"). If this is built, 133 is moot; if
this is deferred, 133 is the thing to do instead. Do not build both.
-## 174. An external `notmuch new` reaches the index without the pending count noticing
-
-**Observed (user, from the notes):** "the statusbar still reads that an
-external notmuch run can clear the pending edits without the bar noticing.
-Since we own mailsync and the whole process, we should fix that."
-
-**Cause (verified in the code, 2026-08-28).** Item 54 built exactly one
-external path and it is narrower than the note assumes. `SyncMonitor` watches
-the inode of `/tmp/mbsync.lock` in `/proc/locks`
-(`syncmonitor.cpp:64`), which is the file `assets/mailsync.sh` flocks, and
-`onExternalSyncStateChanged()` clears `m_pendingTagEdits` only when
-`MailSync::lastRunOutcome()` reads an OK from that script's log
-(`mainwindow.cpp:4812-4840`).
-
-A `notmuch new` that is not that script takes notmuch's own write lock inside
-the Xapian directory and never touches `/tmp/mbsync.lock`. Nothing observes
-it: no state change, no log line to read an outcome from, so the count keeps
-reporting work that has already shipped and the exit prompt offers to sync for
-it. This is item 54's symptom surviving through the one route item 54 did not
-cover.
-
-**Approach (not decided).** The note names the lever: the process is ours, so
-the honest fix is to stop inferring a sync from a lock file and observe the
-INDEX instead. `notmuch_database_get_revision()` gives a monotonic uuid plus
-revision that moves whenever anything is committed, whoever committed it; the
-worker already holds a handle and reopens it. Watching that would cover the
-script, a hand run and a cron entry alike, and would make the outcome question
-moot: a revision that moved is evidence the write landed, where a log line is
-a report about it.
-
-**Constraints.**
-- Clearing the count on an observed revision bump is NOT sound on its own. A
- revision moves for mail arriving as well as for our edits landing, so the
- bump has to be read as "the index changed, re-check" rather than "our edits
- are in". What settles it is the per-message check the pending map can
- already do: every entry names a message and a tag.
-- `SyncMonitor` stays whatever this becomes. It answers a different question,
- "is a sync running", which drives the spinner and the write hold, and a
- revision counter cannot answer it.
-- Item 125 is open on the same monitor and should be read alongside: a
- `mailsync.sh` that exits 75 leaves the spinner running for ever.
-
## 175. The send countdown says Undo, and cannot be skipped
**Observed (user, from the notes):** "the countdown popup has a 'undo' button
@@ -1447,3 +1406,69 @@ same discipline applies here.
the suite with a failure that masks the next real one.
- The suite baseline is currently ONE known failure. Anything that makes it two
intermittently costs the property that a red suite means something.
+
+## 184. New mail waits up to ten minutes, because sync is a fixed cron tick
+
+**Observed (user, 2026-08-29):** "the 10 minutes fixed tick has always bothered
+me, I want the changes to my mail to be immediate, the 10 minutes mark is a
+compromise, it had started at 30 min and was awful."
+
+**Split the want in two, because only half of it is open.**
+
+- **Outgoing is already immediate.** An edit arms `auto_sync_delay_ms`, 2
+ seconds by default (item 71), so marking a message read reaches the server
+ without waiting for the tick.
+- **Incoming is the gap.** Mail that arrives is invisible until the next cron
+ run of `mailsync.sh`, so the wait is uniform on [0, 10] minutes with a mean
+ of five.
+
+**Why a faster tick is not the fix.** Polling every minute is ten times the
+connections and the server load for a mean wait of thirty seconds, and it is
+still a poll: the wait is bounded by the interval however small it gets. The
+answer to "tell me when something arrives" is IMAP IDLE, where the server holds
+the connection and speaks first.
+
+**Where IDLE may live, and where it must not.** `AGENTS.md` states this
+application does NO network protocol work at all: fetching and sending are
+external commands, which is what keeps a mail client out of TLS, authentication
+and an IMAP state machine. IDLE inside qtmaildir would break that rule outright
+and is not on the table.
+
+It does not need to be inside. mbsync has no IDLE mode, being a batch syncer
+that runs and exits; the tools that hold a connection and TRIGGER a sync are
+separate programs (`goimapnotify` and the older `imapnotify` are the usual
+ones). That shape fits the architecture exactly: the watcher replaces the cron
+line, runs `mailsync.sh <channel>` on activity, and qtmaildir stays as ignorant
+of IMAP as it is today. The script's flock still serialises a triggered sync
+against a manual one.
+
+**Blocked on item 174**, and not merely sequenced after it. A watcher makes
+syncs arrive at unpredictable times rather than on a known tick, which makes
+the application's current guesswork about external runs worse. Item 174's
+status file is the reporting channel this needs, and building it first means
+the watcher has a tested one to write into.
+
+**Decisions needed before any code, none of which can be made by reading this
+repository.**
+
+- **Which watcher**, or a small one of ours. A third-party daemon means a
+ SlackBuild in the `my-slackbuilds` repo and a package to maintain.
+- **What the mail server supports.** IDLE is optional in IMAP, and a server
+ without it leaves polling as the only mechanism.
+- **How many connections.** One per account per watched folder, held open
+ indefinitely; some servers cap concurrent connections.
+
+**Constraints.**
+
+- **Keep a slow cron tick as a backstop.** A held connection drops on network
+ sleep, a server restart or a laptop suspend, and a watcher that dies silently
+ stops mail altogether, which is worse than a ten minute wait. Belt and
+ braces: the watcher for latency, a slow tick so a dead watcher is survivable.
+- **The lock is the shared mutex** and stays so. A triggered sync, a cron sync
+ and a click in qtmaildir must continue to serialise through
+ `/tmp/mbsync.lock`, or two mbsync runs corrupt Maildir UID state.
+- **This is not a qtmaildir daemon.** A long-running qtmaildir service was
+ considered and rejected in the same conversation: it answers none of items
+ 174 or 125 better than a file does, and it adds a process that can wedge and
+ take mail delivery with it. What the user wants is a watcher, which is a
+ different thing in a different place.
diff --git a/docs/superpowers/specs/2026-08-29-sync-status-file-design.md b/docs/superpowers/specs/2026-08-29-sync-status-file-design.md
new file mode 100644
index 0000000..b5c3a40
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-29-sync-status-file-design.md
@@ -0,0 +1,177 @@
+# A status file, so qtmaildir is told about a sync instead of inferring it
+
+Item 174, and item 125 with it. 2026-08-29.
+
+## The premise, which is the user's and which reverses the usual direction
+
+`assets/mailsync.sh` was written for another system and adapted for qtmaildir.
+It is now the only consumer, so the script serves the application rather than
+the application accommodating the script. Where a gap is found in qtmaildir,
+the script is reshaped to bridge it. mbsync and IMAP stay out of the
+application; the script stays a commodity.
+
+Two facts settle the scope, both verified rather than assumed:
+
+- The user's crontab runs `mailsync.sh` every ten minutes and nothing else
+ touches mail. There is no third-party mbsync and no bare `notmuch new`.
+- `~/bin/mailsync.sh` is a SYMLINK to `assets/mailsync.sh` in this repo, so an
+ edit here is live on the next cron tick with no deploy step.
+
+## What is actually wrong
+
+qtmaildir learns about a sync it did not start through two indirect channels,
+both of which are inferences about a process that has already exited:
+
+- **An inode in `/proc/locks`** (`syncmonitor.cpp:64`), watching the file the
+ script flocks. This answers "is a sync running", and it is the reason item
+ 125 exists: a skipped run (exit 75) releases no lock the monitor ever saw
+ held, so the spinner runs for ever.
+- **A grep of the log** for `RUN END ... status=OK`
+ (`MailSync::lastRunOutcome`, one production caller at `mainwindow.cpp:5182`).
+ This makes a human-readable log line into load-bearing wire format: anyone
+ reformatting that banner breaks the application silently.
+
+Neither channel carries what the application needs, which is WHICH CHANNELS a
+run synced, WHEN, and WITH WHAT RESULT. The consequence is visible in the code:
+the local sync path narrows its clear to the accounts the run carried
+(`mainwindow.cpp:4623`, `subtract(accountsThisRunCarried)`), while the external
+path cannot and does a blanket `m_pendingTagEdits.clear()` at `:5183`. The
+external path is coarser than the local one for want of information the script
+has and does not report.
+
+## What is NOT wrong, and must not be "fixed"
+
+**A bare `notmuch new` cannot clear the pending count, and should not.** The
+count means "confirmed tag mutations not yet known to have reached the MAIL
+STORE" (`mainwindow.h:1763`), which is the server. A tag edit is in notmuch the
+moment it is made; what is outstanding is mbsync pushing the renamed Maildir
+files. `notmuch new` re-indexes local files and pushes nothing.
+
+Item 174's own "Approach" section proposes watching
+`notmuch_database_get_revision()`. That is rejected here: a revision moves when
+mail ARRIVES as well as when our edits land, and in neither case does it say
+anything about the server. Clearing on a revision bump would make the indicator
+claim work is safe to quit on when it is still local, which is item 28's defect
+returning in a new costume. The item's own constraint gestures at this and then
+resolves it with a per-message re-check, but that check reads notmuch, which
+also cannot see the server.
+
+The status file is the correct instrument precisely because the script knows
+what notmuch cannot: whether MBSYNC ran and what it returned.
+
+## The design
+
+The script writes one JSON file at the end of every run, including a skipped
+one. qtmaildir watches that file.
+
+**Path:** `~/.local/state/qtmaildir/syncstatus.json`, configurable beside
+`syncLog` for the same reasons that key exists. Note it goes under the
+application's own state directory, not beside `mailsync.log` in
+`~/.local/state/`: the log is the script's, the status file is the interface.
+
+**Shape:**
+
+```json
+{
+ "version": 1,
+ "run_id": "2026-08-29T10:33:07+02:00",
+ "started": "2026-08-29T10:33:07+02:00",
+ "ended": "2026-08-29T10:33:41+02:00",
+ "state": "ok",
+ "channels": ["work", "personal"],
+ "mbsync_status": 0,
+ "notmuch_status": 0
+}
+```
+
+- `state` is `ok`, `failed` or `skipped`. Three states, not a boolean, because
+ a skip is neither: item 125 exists because a skip currently reads as neither
+ success nor failure and so resolves nothing.
+- `channels` is the channel list the run actually synced, or `["-a"]` for a
+ full run. This is what lets the external path narrow its clear the way the
+ local path already does.
+- `version` is refused rather than guessed at by a reader that does not know
+ it, following the rule the rules file already uses for `kFormatVersion`.
+
+**Written atomically**, to a temporary file in the same directory and then
+`mv`, which is atomic within a filesystem. A reader watching the file must
+never see a half-written one, and qtmaildir will be watching it while it is
+written.
+
+**The log keeps its banner.** `RUN END` stays exactly as it is, and
+`MailSync::lastRunOutcome()` stays with its eight tests. It becomes the
+FALLBACK for a status file that is missing or unreadable, which is what a first
+run after upgrading looks like, rather than being deleted in the same change
+that adds its replacement.
+
+## What each side does
+
+**`assets/mailsync.sh`.** Collect the channel list, write the file on every
+exit path. The skip branch at the top exits before the status directory exists,
+so it needs its own write. Two properties recorded as load-bearing survive
+untouched: printing to stdout as well as the log, and exiting with the real
+status.
+
+**`src/mailsync.{h,cpp}`.** A `SyncStatus` value struct and a static reader,
+beside `lastRunOutcome()` and in the same shape: a pure function of a path,
+returning a value with an Unknown-equivalent for every failure. No widget, no
+process.
+
+**`src/syncmonitor.{h,cpp}`.** Watch the status file with `QFileSystemWatcher`,
+beside the existing `/proc/locks` polling rather than replacing it. The lock
+answers "a sync is running now" and the file answers "a run finished and here
+is what it did"; these are different questions and a completion record cannot
+answer the first.
+
+**`src/mainwindow.cpp`.** The external path at `:5182` reads the status file
+instead of the log, and narrows its clear by channel, resolving channels back
+to accounts through `Account::channel`. A `skipped` state clears the spinner
+without clearing the count, which is item 125.
+
+## Constraints
+
+- **The script is live.** Every edit reaches real mail within ten minutes.
+ Writing the status file is purely additive and nothing reads it until the
+ application does, so a partial state is harmless, but the ORDER matters: the
+ script lands first and is left to run for a few cycles before the reader is
+ written.
+- **`Account::channel` may differ from the account key** and may be empty,
+ in which case the key is the channel. A reverse lookup must handle both, and
+ a channel naming no account must be ignored rather than dropped on the floor
+ silently.
+- **`-a` means every account**, and is not the same as a list naming them all.
+ A reader that treats `["-a"]` as an unknown channel clears nothing on exactly
+ the run that carried everything.
+- **Only a successful run may clear the count**, per the rule the local path
+ states at `:4614`. `failed` and `skipped` clear no edits.
+- **Unknown changes no state**, exactly as `SyncOutcome::Unknown` and
+ `SyncMonitor::State::Unknown` are treated today.
+- **Two readers, one format.** This is the `rules.json` situation again, a bash
+ writer and a C++ reader agreeing by test rather than by shared code. The
+ discipline in AGENTS.md under "Changing the rule format" applies: change both
+ sides together, bump the version only for a breaking change, and run both
+ suites.
+- **No new dependency for JSON.** The script writes it with `printf`, which is
+ why the shape above has no nesting; qtmaildir reads it with `QJsonDocument`,
+ which it already uses for `queries.json`.
+
+## Tests
+
+- `assets/hooks/`-style Python test for the script, in the shape the two hook
+ suites already take: run it with a stub `mbsync` and `notmuch` on PATH,
+ assert the file's contents for ok, failed and skipped.
+- `test_mailsync.cpp`: the reader, against files written by hand. Every failure
+ mode returns the Unknown equivalent, including a truncated file, an unknown
+ version, and a file that is not JSON at all.
+- `test_mainwindow.cpp`: the external path clears the count for a channel it
+ carried and leaves an account it did not, which is the behaviour the blanket
+ clear cannot express. And a `skipped` run clears the spinner and no edits.
+
+## What this deliberately does not do
+
+- No daemon, no socket, no D-Bus. The file is the interface.
+- No change to how a LOCAL sync reports: it has the process and its exit
+ status, which is better evidence than a file.
+- No backfill of item 125's other half. The spinner is cleared by a `skipped`
+ record here; whether `SyncMonitor` should also time out an observation it
+ never saw end is a separate question and stays in item 125.