diff options
| -rw-r--r-- | CLAUDE.md | 101 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 8 |
2 files changed, 56 insertions, 53 deletions
@@ -233,8 +233,8 @@ process-wide, so holding it open would block the user's cron `notmuch new`. `app closes the read-only handle, opens read-write, applies, closes. notmuch permits only one open handle per process, so that close-first ordering is required, not stylistic. -**No dry-run, no destructive-action confirmation.** Those gates exist in the companion -project `../mailctl` to restrain an agent; a human at a GUI gets **undo** instead — every +**No dry-run, no destructive-action confirmation.** Those gates belonged to the retired +`mailctl` CLI, where they restrained an agent; a human at a GUI gets **undo** instead — every mutation pushes its inverse (`TagChange::inverted()`) onto a `QUndoStack`. Do not add confirmation dialogs for tag mutations. All actions funnel through one `applyTags` path; multi-row selections go through `applyTagsToThreads`, which resolves every thread in ONE @@ -295,9 +295,9 @@ irreversibly. A restore must be right about its destination or it is worse than doing nothing. **The sync script lives here, in `assets/mailsync.sh`.** It moved from the -companion `mailctl` project, which documents that it never calls it: the script -is `mbsync` plus `notmuch new` with a lock, and qtmaildir is the only thing that -runs it programmatically. Two properties exist for this application's sake and +retired `mailctl` project, which never called it: the script is `mbsync` plus +`notmuch new` with a lock, and qtmaildir is the only thing that runs it +programmatically. Two properties exist for this application's sake and must survive any edit. It **prints to stdout as well as its log file**, because `MailSync` shows what the command prints and a self-redirecting script leaves the pane empty; and it **exits with the real status**, because a `0` from a @@ -357,18 +357,19 @@ asserts on is guarded by `!m_savedQueries.isEmpty()`, so a test with no `queries.json` never reaches the branch and passes against a broken check. It writes one, and asserts the file loaded before asserting on what it produced. -**This application has a sibling, and one file couples them.** `mailctl` -(`../mailctl`) is a narrow, agent-safe CLI over the same notmuch index. The two -are independent except for `~/.config/mailrules/rules.json`, which both read and -write. **Before changing anything about that file's format, read -"Changing the shared rule format" at the bottom of this document.** Nothing else -here can break mailctl: it never imports from this repo, and this repo never -calls it. - -**The auto-tagging rules are NOT in this repo, and notmuch's parser rejects -almost nothing.** Rules live in `~/.config/mailrules/rules.json`, applied by a -notmuch `post-new` hook that ships from the companion `mailctl` project; -`TagRules` here reads and writes the same file and `TagRulesDialog` edits it. +**One config file has two readers, and both are now in this repo.** +`~/.config/mailrules/rules.json` is read and written by `src/tagrules.cpp` and +by `assets/hooks/mailrules.py`, which share no code and agree by test. +**Before changing anything about that file's format, read "Changing the rule +format" at the bottom of this document.** It used to be a cross-repo coupling +with the `mailctl` CLI; that project is retired and the hooks moved here on +2026-08-23, so a format change is now one repo and two suites. + +**The auto-tagging rules live in a config file, not in the source, and notmuch's +parser rejects almost nothing.** Rules are in `~/.config/mailrules/rules.json`, +applied by the notmuch `post-new` hook in `assets/hooks/`, which the live +`database.hook_dir` symlinks to; `TagRules` here reads and writes the same file +and `TagRulesDialog` edits it. Two things bite. A stored query carries NO scope: the hook supplies `tag:new` and wraps the query in parentheses, because `tag:new and a or b` binds as `(tag:new and a) or b` and a rule that is a disjunction of senders would escape @@ -885,12 +886,12 @@ backlog had already specified and that shipped unbuilt (item 29). A note saying "X does not work" is a bug report, and it will sit in a personal notes file indefinitely unless someone goes looking. -**The backlog covers the mail system, not only this binary.** Item 44 shipped as -commits in BOTH this repo and `../mailctl`, and any future item touching the -shared rule format will too. An item is not "not ours" because its work lands in -the sibling repo; note where the work goes in the table's Note column. mailctl -keeps its own `TODO.md` for things that are purely its own, and that file is not -part of this reconciliation. +**The backlog covers the mail system, not only this binary.** An item can land +in `assets/hooks/` rather than in `src/`, and item 166 is one: the tagging hook +is part of the mail system the user sees, so a defect there gets an item here +like any other. Item 44 predates that and shipped as commits in this repo and in +the retired `mailctl`, which is why older entries mention a sibling repo; there +is no longer one to split work across. **Then print the open items as a table, and stop.** The user picks what to work on; do not start on one, and do not recommend a single item as though the choice @@ -993,39 +994,41 @@ was: bumping it is a task in that repo, which has its own workflow in its tracks upstream through an nvchecker stanza there, so a release here is picked up by that repo's own sweep. -## Changing the shared rule format +## Changing the rule format -`~/.config/mailrules/rules.json` has **two independent implementations**, and -they agree by test rather than by sharing code: +`~/.config/mailrules/rules.json` has **two independent implementations**, both +in this repo, and they agree by test rather than by sharing code: | | reads/writes | applies rules | |---|---|---| -| `src/tagrules.cpp` (here) | yes | no | -| `mailrules.py` (`../mailctl`) | yes | via the `post-new` hook | - -**This is the only way work here can break mailctl.** It never imports from this -repo and this repo never calls it, so nothing else is shared. The file is -deliberately owned by neither: both readers preserve fields they do not -understand (`TagRule::unknown`, `Rule.unknown`), which is what lets one tool -save a file the other wrote without stripping it. - -**A format change is therefore a two-repo change, and the live hook runs every -ten minutes on real mail.** Before touching the schema: - -1. Change both readers, not one. A field added here and not there is silently - dropped on the next save from the other side, which looks like data loss with - no error anywhere. +| `src/tagrules.cpp` | yes | no | +| `assets/hooks/mailrules.py` | yes | via the `post-new` hook | + +They are two languages either side of one file, so nothing but the format +couples them. Both readers preserve fields they do not understand +(`TagRule::unknown`, `Rule.unknown`), which is what lets one save a file the +other wrote without stripping it. This was a cross-repo coupling with the +`mailctl` CLI until that project was retired and the hooks moved here on +2026-08-23; the discipline below survives the move because the two readers do. + +**The live hook runs every ten minutes on real mail.** Before touching the +schema: + +1. Change both readers, not one. A field added on one side and not the other is + silently dropped on the next save from the other, which looks like data loss + with no error anywhere. 2. Bump `kFormatVersion` / `FORMAT_VERSION` together only for a BREAKING change. Both readers refuse a file whose version they do not know, which is the - correct behaviour and also means a half-deployed bump stops the hook from + correct behaviour and also means a half-applied bump stops the hook from tagging. Adding an optional field needs no bump. -3. Run both suites: `ctest --test-dir build -R tagrules` here, and - `./test_mailrules.py && ./test_post_new.py` there. -4. Verify the round trip across tools by hand, since no automated test spans - both repos: save from the dialog, then `mailctl rules list`, and confirm the - rule count and a note survive. - -**Two hook properties are safety-critical and are not this repo's to weaken.** +3. Run both suites: `ctest --test-dir build -R tagrules`, and + `./test_post_new.py && ./test_mailrules.py` from `assets/hooks/`. +4. Verify the round trip by hand, since no automated test spans the C++ and the + Python: save from the dialog, then run the hook over a throwaway index, and + confirm the rule count and a note survive. + +**Two hook properties are safety-critical, and being ours now is not a reason to +weaken them.** The hook refuses to remove `unread` or `inbox` (`maildir.synchronize_flags` is true, so removing `unread` rewrites Maildir filenames and reaches the server), and it does not consume the `tag:new` marker when the rules fail to load 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 5b843a4..6c29fe0 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 @@ -239,7 +239,7 @@ taking that too literally. | 163 | The message pane shows a stale path, and the composer forks the draft | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix while the model still holds the name the query returned. `MaildirName::resolveRenamed()` returns the path unchanged when it exists, else finds the file in that one directory whose unique stem matches; it refuses an ambiguous match and yields nothing for a genuinely missing file. Wired into all THREE read sites: the pane, Reply/Forward, and the draft reopen. The reopen was the one that cost data, forking a draft into two files with two Message-IDs, both reaching the server | | 164 | A draft this application saved keeps `inbox` | defect | S | open, 2026-08-25, **cause corrected 2026-08-25**. The first diagnosis blamed a missing drafts helper and was WRONG: `NOT_ARRIVALS` in `qtmaildirconf.py` is `("sent", "drafts")`, the folder list includes every account's drafts folder, and `notmuch count` confirms the carve-out query MATCHES the affected draft. The carve-out is scoped to `tag:new`, and the draft carries `inbox` while `tag:new` is 0, so it was never in scope when the hook ran. Measured separately: an mbsync-style rename does NOT re-add `new.tags`, so the retag theory is out too. What remains unestablished is WHICH pass tagged it; establish that before writing code | | 165 | A draft gets a new Message-ID on every autosave | enhancement | ? | open, 2026-08-25, found while hand-testing 163 and 164. `MessageBuilder::build()` generates an id unconditionally and every autosave calls it, so each revision is a distinct MESSAGE to notmuch and to the server rather than a new version of one. Invisible while the file is replaced correctly, which item 163's fix restores; it is what turned that fork into two messages rather than one duplicated file. Needs a DECISION on what a draft's identity is before any code: a stable id reused at send, a stable id discarded at send, or the status quo. Neither `ComposeContext` nor `OutgoingMessage` has a field to carry an id, so it is not a changed call site | -| 166 | Mail you send to your own other account loses `inbox` | defect | S | open, found 2026-08-25. Two-repo change: the carve-out lives in the `post-new` hook, mirrored in mailctl's `mailrules.py` | +| 166 | Mail you send to your own other account loses `inbox` | defect | S | open, found 2026-08-25. Wholly this repo's: the hooks live in `assets/hooks/` with their own suites, and the live `post-new` symlinks to them | | 167 | No way to tell one build of an unreleased version from another | enhancement | XS | open, found 2026-08-25 from the notes, unrecorded until now. `src/version.h.in` interpolates `PROJECT_VERSION` alone, so every build between two releases reports the same string and a rebuilt binary cannot be told from the one it replaced. The user runs unreleased builds daily, which is when it matters. Needs a DECISION on the source: the git describe/short hash (accurate, needs the build to re-run cmake to pick up a new commit) or a monotonic counter (always moves, means nothing on its own) | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -586,9 +586,9 @@ Option 1 is the one that makes the code true to what it already says it does. **Constraints.** -- **This is a two-repo change.** The carve-out is in this repo's `post-new`, - but the shared rule machinery is mirrored in `../mailctl`. Read "Changing the - shared rule format" in CLAUDE.md before touching it, and run both suites. +- **The hook is this repo's**, `assets/hooks/post-new`, which the live + `database.hook_dir` symlinks to. It has its own suites beside it; run + `./test_post_new.py` and `./test_mailrules.py` from `assets/hooks/`. - The hook **tags real mail unattended, every ten minutes, on the user's live index.** A predicate that is wrong in the other direction would strip `inbox` from arriving mail, which is the failure mode PROTECTED_REMOVALS exists to |
