# Skill Email Cleanup

> Ad-hoc wrapper-only email triage - census, classify, review, confirmed archive/delete/unsubscribe-extract. Default 50-step mode, --all whole-mailbox mode, --archive scope to the account's archive folder. Gmail and Logos accounts supported. Invoke for /email command.

- Skill: `benbrastmckie/skill-email-cleanup` (Agent Skill)
- Install (CLI): `npx skillmds@latest add benbrastmckie/skill-email-cleanup`
- Raw SKILL.md: https://api.skillmd.com/api/skills/benbrastmckie/skill-email-cleanup/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: benbrastmckie (https://skillmd.com/u/benbrastmckie)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/benbrastmckie/skill-email-cleanup

---


# Email Cleanup Skill (Direct Execution)

Direct-execution skill for email triage, invoked by `/email`. Runs a
census -> classify -> review -> confirmed-execute pass without dispatching to a subagent. This
skill is wrapper-only: it may invoke ONLY the five named nix-built binaries below, by name, and
must NEVER call raw `himalaya`, `notmuch`, `msmtp`, or `secret-tool`, and must NEVER run `rm`
against a Maildir path. The one sanctioned exception is the index-only `email-reindex` operator
helper (staleness remediation) — non-mutating, exempt exactly as the `mbsync` reconcile
is; see the Staleness Remediation section.

An account selector, two decision-granularity modes, and an orthogonal folder scope (parsed by
`/email`, passed in as args):

| Arg | Values | Meaning |
|-----|--------|---------|
| `account` | `gmail` (default, no flag) / `logos` (`--account logos` or `--logos`) | which mailbox account BASE_QUERY, the pilot gate, and every wrapper `--account` call resolve against. Resolved ONCE and threaded unchanged through the whole invocation. `account=logos` is a live, accepted account subject to a light liveness check: see "Account Liveness Check" below. |
| `mode` | `default` (no flag) / `all` (`--all`) | bounded 50-step pass vs. whole-mailbox sweep + one bucket approval + sub-50 drain |
| `scope` | `inbox` (no flag) / `archive` (`--archive`) | classify QUERY base — account-dependent, see Stage 0 |
| `focus_hint` | free text | optional extra narrowing (sender/domain/topic) |

**MANDATORY INTERACTIVE REQUIREMENT -- DO NOT SKIP**:
- The human review gate is NEVER skipped in any mode. In `mode=default` it is Stage 3 (per-message
  candidate review). In `mode=all` it is Stage 2.5 (one consolidated bucket approval).
- Do NOT construct or pass `--execute --confirm-manifest <sha256>` to any binary until the user
  has explicitly approved the reviewed candidate set in this conversation.
- The review-gate AskUserQuestion and ALL execute calls run in the ROOT SESSION via direct
  execution — never inside a background job or subagent. The ONLY thing that may ever run in the
  background is the read/tag-only classify sweep of `mode=all` Stage 2.

## $PATH Precondition (contract §9 — check before Stage 1)

```bash
command -v email-census email-classify email-archive-confirmed email-delete-confirmed \
  email-unsubscribe-extract
```

If any binary is missing, stop and tell the user to run `home-manager switch --flake .#<user>`
to activate the generation containing `modules/home/email/agent-tools.nix`. Do not fall back to
a raw `himalaya`/`notmuch` call.

## Account Liveness Check (`account=logos` only — check before Stage 1)

The five wrapper binaries accept `--account <gmail|logos>` as a live enum (wrapper-contracts.md
§2, verified 9/9 against the `.dotfiles` wrapper suite); unknown values are rejected loudly. This is not a
permanent gate on `logos` — it is a light read-only liveness check that guards against a
transient/environmental problem (e.g. a stale `$PATH` generation predating multi-account
support). When `account=logos` is resolved (from `/email`'s `--account logos`/`--logos`), this
skill confirms the wrapper accepts it BEFORE any binary call by running `email-census --account
logos` (read-only, side-effect-free per the Five Binaries safety-class table below) and checking
its exit code and stderr — NOT `email-census --account logos --help`, which short-circuits
before flag validation (wrapper-contracts.md line 41: `--help` prints verb/safety-class/flags
unconditionally) and would never actually test account acceptance. If the probe exits non-zero
or reports an account-rejection error, STOP immediately and report:

> `/email --logos` failed its liveness check — `email-census --account logos` did not succeed.
> Run `home-manager switch --flake .#<user>` to activate the generation with multi-account
> support, then retry.

This is an ACTIONABLE, LOUD failure — never a silent continuation against `gmail`. Do not
construct or run any `folder:Logos*` query, and do not touch Gmail either, if the liveness check
fails. `account=gmail` is entirely unaffected by this check (it is today's already-accepted
default value) and proceeds straight to the `$PATH` check above.

## The Five Wrapper Binaries (the ONLY binaries this skill may invoke)

| Binary | Safety class | Mutates? |
|--------|--------------|----------|
| `email-census` | read-only | no |
| `email-classify` | local-tags-only (notmuch tags, never maildir/IMAP) | no (tags only) |
| `email-unsubscribe-extract` | read-only | no |
| `email-archive-confirmed` | mutation | yes (maildir move) |
| `email-delete-confirmed` | mutation | yes (maildir move + optional `--expunge-trash`) |

## Execution Flow

### Stage 0: Mode and Scope Dispatch

Resolve the branch before any binary call:

0. **Account liveness check**: if `account=logos`, run the Account Liveness Check above FIRST;
   do not proceed to step 1 until it passes. `account` is resolved exactly ONCE here and threaded
   unchanged (same value) to every wrapper call, the pilot gate, and both `BASE_QUERY` branches
   below for the rest of this invocation — never re-resolved mid-flow.
1. **Base query from account + scope** (folder: tokens ONLY — never `tag:<account>`, which is
   live but deliberately not relied on for wrapper scoping; no fallthrough to a
   Gmail token for a non-gmail account):

   | `account` | `scope=inbox` | `scope=archive` |
   |-----------|---------------|------------------|
   | `gmail` (default) | `BASE_QUERY="folder:Gmail"` | `BASE_QUERY="folder:Gmail/.All_Mail"` |
   | `logos` | `BASE_QUERY="folder:Logos"` (bare root = INBOX) | `BASE_QUERY="folder:Logos/.Archive"` (the real Proton Archive folder — Logos has no `.All_Mail`/`.Spam`) |

   These are the exact tokens from wrapper-contracts.md §11 (gmail) and the Logos ground-truth
   folder census — folder scoping is ONLY ever expressed as (part of) the
   `email-classify` QUERY positional; no wrapper flag exists for it.

   The verified `folder:`-syntax forms (live-verified; see
   `domain/index-architecture.md`):

   | Form | Example | Live behavior |
   |------|---------|---------------|
   | Glob (broken, never in wrapper source) | `folder:Gmail*` | 0 matches — notmuch `folder:` does not glob |
   | Bare exact-match (the `/email` wrappers, by design) | `folder:Gmail` | INBOX-only exact maildir-folder match |
   | Regex (aerc querymap) | `folder:/Gmail/` | Whole-account match across all folders |

   A `tag:<account>` scheme (`tag:gmail`/`tag:logos`) is live (populated by the
   `postNew` hook, exactly matching `folder:/Gmail/` / `folder:/Logos/`) but wrapper scoping
   deliberately never relies on it.
2. **Focus terms**: translate `focus_hint` (if any) into additional notmuch query terms
   (e.g. `from:github.com`) appended to `BASE_QUERY` with `and`.
3. **Branch on mode**: `mode=default` -> Default Mode flow below; `mode=all` -> `--all` Mode
   flow below.
4. **Archive gates**: if `scope=archive`, apply the extra-caution gates in the
   "Archive Scope (`scope=archive`)" section to whichever mode flow runs, including the
   PER-ACCOUNT pilot gate check BEFORE any sweep or classify pass.

---

## Default Mode (`mode=default`): Bounded 50-Step Pass

The safer default. Each pass classifies at most 50 messages, is reviewed per-message, and by
construction can never trip the wrapper's `enforce_batch_size` cap — so this mode never needs
classify chunking (input is bounded to 50) and never needs manifest splitting (the approved set
is ≤ 50 per action). Repeated bare `/email` runs make real forward progress via the cursor rule
in Stage 2.

### Stage 1: Census

Run `email-census --account <account>` (dry-run/read-only by nature) to summarize senders,
folders, and date ranges. Present a brief summary to the user.

### Stage 2: Classify (with cross-invocation cursor)

Run `email-classify --account <account> --limit 50 "<CURSOR_QUERY>"` to produce a candidate
manifest (JSONL keyed on Message-ID) with `proposed_action` (`delete|archive|keep|unsure`) and
`confidence` per message, following the recall-on-keep-bias standard (near-100% recall on
`keep`; delete auto-proposed only at confidence `>= 0.90`, otherwise `unsure`).

**Cursor rule** — once a mailbox has had at least one default pass, exclude already-classified
messages so each re-run advances by construction:

```
CURSOR_QUERY = <BASE_QUERY + focus terms> and not tag:proposed-delete
               and not tag:proposed-archive and not tag:proposed-unsure
               and not tag:proposed-keep
```

Why this advances: `email-classify` tags every message it processes with exactly one durable
`+proposed-*` notmuch tag (wrapper-contracts.md §10). Executed messages leave the folder
entirely; declined-but-seen messages keep their durable tag and are excluded by the cursor. So
a second bare `/email` classifies a different 50 than the first, and successive runs step
through the mailbox oldest-remaining-first (newest-first within each pass). The cursor is
expressed purely as an `email-classify` QUERY argument — the skill never issues a raw
`notmuch tag`/`notmuch search` command. On a fresh mailbox (no `+proposed-*` tags), the cursor
query is equivalent to the plain scoped query.

To deliberately revisit previously-declined messages, use `/email --all` (the whole-mailbox
sweep re-surfaces them); the default cursor intentionally skips them.

**Cursor rule stays account-agnostic by construction**: `CURSOR_QUERY` is built on top of the
now-account-aware `BASE_QUERY` (Stage 0), which already confines every account to its own
disjoint folder subtree (exact-match `folder:Gmail`/`folder:Gmail/.<Sub>` vs
`folder:Logos`/`folder:Logos/.<Sub>` tokens — notmuch `folder:` does not glob). The
`+proposed-*` tags themselves are
per-message notmuch tags, not account-scoped, but because the two accounts' folders never
overlap, a Gmail pass and a Logos pass can never tag, cursor-exclude, or collide on the same
message — no additional account qualifier is needed in the tag-exclusion terms.

### Stage 3: Review (mandatory stop)

Present the candidate manifest to the user via AskUserQuestion. Allow the user to approve some,
all, or none of the proposed actions. Do not proceed without an explicit response.

### Stage 4: Confirm

Once the user approves a set of actions, an approved manifest (git-tracked) is produced/updated
and its sha256 (over the raw manifest bytes) is computed.

### Stage 5: Execute

Invoke `email-archive-confirmed --account <account>` and/or `email-delete-confirmed --account
<account>` with `--execute --confirm-manifest <sha256>` for the approved manifest only — the
SAME `account` resolved in Stage 0, never re-resolved. Optionally run
`email-unsubscribe-extract --account <account>` (read-only) to surface `List-Unsubscribe`
candidates for senders the user flagged.

### Stage 6: Verify

Diff the wrapper's own execution-state output (never re-derived) against the approved manifest
to confirm which IDs were actually mutated. Report this diff to the user.

### Stage 7: Harvest (opt-in, never-silent)

An opt-in step that routes wrapper-confirmed decisions from this pass into the memory vault as
sender/domain-aggregated `email/preferences/{account}/{key}` preference memories. Grounded in
the authoritative design at
`context/project/email/design/email-to-memory-preferences.md` — see that document
for the full rationale; this section is the executable prose. Fires ONLY after Stage 6, reading
ONLY the Stage 6 *executed* diff — never the Stage 2 candidate manifest (`proposed_action`,
unconfirmed) and never an approved-but-not-yet-executed manifest.

1. **Key derivation (default mode)**: for every Message-ID the Stage 6 diff confirms as
   `executed` this pass, look up its `sender` field in the approved manifest and derive its
   identity via `.claude/scripts/email-preference-harvest.sh identity "<sender>"` (add
   `--rollup` only for a caller-chosen non-freemail domain rollup — never for freemail/shared
   domains). Group executed IDs by the resulting `.key`, prefixed by the resolved account:
   `email/preferences/${ACCOUNT}/${KEY}`. Never key on Stage 2/3/4 unconfirmed lines.
2. **Mixed-sender handling (design §1.5)** is a first-class branch, not an edge case: when a
   group's confirmed actions this pass are heterogeneous with no uniform majority, either split
   by a subject/category token into distinct keys, or decline to aggregate that portion this
   pass (leave it as unresolved tally noise) — never average into a false scalar action.
3. **Per-key dedup + tally update**: for each key's group, determine this round's action-count
   breakdown (e.g. `{archive: 6}` for a uniform group, or split per mixed-sender handling), then:
   ```bash
   EXISTING=$(bash .claude/scripts/email-preference-harvest.sh dedup .memory/memory-index.json \
     "email/preferences/${ACCOUNT}/${KEY}")
   EXISTING_TALLY=$(echo "$EXISTING" | jq -c '.tally // null' 2>/dev/null || echo null)
   RESULT=$(bash .claude/scripts/email-preference-harvest.sh tally-op "$EXISTING_TALLY" \
     "$ACTION" "$COUNT" "$(date -u +%Y-%m-%d)")
   OPERATION=$(echo "$RESULT" | jq -r '.operation')   # CREATE | EXTEND | UPDATE
   NEW_TALLY=$(echo "$RESULT" | jq -c '.tally')
   ```
   (Note: a real memory file's tally block, not `.tally` on the raw index entry, is the ground
   truth — read it from the memory file body if the index entry alone is insufficient; the
   `dedup` subcommand's index lookup is for the exact-key hit/miss decision, per
   `skill-learn/SKILL.md`'s "Exact-Key Dedup for Reserved Namespaces".)
4. **Evidentiary threshold gate**: only keys/operations meeting
   `.claude/scripts/email-preference-harvest.sh threshold "$NEW_TALLY" "$UNIFORM"` (uniform-batch
   this round, or rolling N>=3 at >=80% against the post-update tally) become Tier 1 candidates
   below; a single isolated confirm below both bars is still tallied (Step 3) but is not
   presented as a strong preference this round.
5. **Archive-scope isolation**: if this pass's scope is `scope=archive`, record the tally delta
   in the memory's `### Archive-scope tally` sub-section (per `skill-learn/SKILL.md`'s
   Namespace-Scoped Tally-Arithmetic subsection) instead of the inbox-scope tally — never merge
   the two.
6. **One consolidated, never-silent gate** (`AskUserQuestion`, root session), mirroring
   `skill-todo`'s harvest -> dedup -> tiered-gate -> batch-regen *logic only* (never its
   `state.json`/`project_number` substrate):
   - **Tier 1** (pre-selected): keys meeting the evidentiary threshold outright this round (Step
     4).
   - **Tier 2** (shown, not pre-selected): keys newly crossing the rolling-N threshold this
     round.
   - **Fuzzy near-miss suggestions** (skill-learn's retained fuzzy path, §4.2): surfaced as a
     labeled option, never auto-selected.
   - Skip the gate entirely (no prompt) if this pass produced zero candidates in any tier —
     harvest is opt-in per-round, not a mandatory stop when there is nothing to offer.
7. **Write on confirm — Bash/jq file-write path** (this skill's `allowed-tools` is `Bash, Read,
   AskUserQuestion`; memory files are written via Bash heredoc/`jq`, never via a `Write`/`Edit`
   tool call or a `skill-learn`/`/learn` dispatch): for each user-confirmed key, write or update
   `.memory/10-Memories/MEM-email-pref-{key-slug}.md` using the body template from
   `skill-learn/SKILL.md`'s Namespace-Scoped Tally-Arithmetic subsection (frontmatter incl.
   `topic: "email/preferences/${ACCOUNT}/${KEY}"`, `category: preference`; CREATE writes the
   full template, EXTEND appends a dated `## History` line, UPDATE additionally moves the prior
   summary line to `## History` marked `(superseded)`).
8. **Batch index regeneration** (design §4.4): regenerate `.memory/memory-index.json` **once**
   after the entire harvest round (all confirmed keys in one gate response), never per
   individual CREATE/UPDATE/EXTEND — independent of the `--clean` flag (`--clean` only suppresses
   `/research`/`/plan`/`/implement` auto-retrieval; it has no relationship to this write-side
   gate).
9. **Feedback-loop cap (design §5.3, MUST NOT)**: the harvest never mutates `proposed_action` or
   raises `confidence` on any future classify pass — it may only ever contribute as a surfaced
   tally at a future gate/read-back. The vault stays strictly advisory relative to the frozen
   classifier.
10. **Revocation/edit UX (design §5.5)**: a user-invoked "forget this preference" option, offered
    alongside the harvest gate or on request, reuses the existing tombstone pattern
    (`status: tombstoned`, `tombstoned_at`, `tombstone_reason: "user_revoked"`) or a tally reset —
    distinct from `/distill --purge` (automatic, staleness-driven) and never automatic.
11. **Minimal success-signal logging (design §5.5)**: for keys with a pre-existing memory prior
    to this round, log a per-round agreement rate (confirmed action == the memory's pre-round
    derived dominant action) as one harvest log line — no new dashboard, enough visibility for a
    future audit to detect drift.

---

## `--all` Mode (`mode=all`): Whole-Mailbox Sweep, One Bucket Approval, Sub-50 Drain

The whole-mailbox operation. The wrapper's 50-cap disappears from the user's experience: the
classify sweep is unconditionally chunked, the human decision is ONE consolidated sender/domain
bucket approval, and execution is a mechanical drain of ≤50-per-action sub-manifests with
progress-only reporting. Never raise `MAX_BATCH_SIZE` — this mode exists precisely so that
looping in ≤50 splits replaces any temptation to touch the frozen wrapper.

**Pagination mechanism (ground-truth adapted)**: the round-2 design called for capturing the
in-scope message-ID list once and slicing it into `id:a or id:b ...` chunk queries. Phase-1
verification (wrapper-contracts.md §10) found that NO wrapper emits a complete message-ID list
for an arbitrary query, and capturing one any other way would require a raw `notmuch search` —
forbidden. Per the plan's pre-authorized fallback, the sweep therefore paginates with
`--limit`-based chunking driven by the QUERY positional:

- **New messages** (never classified): repeated
  `email-classify --account <account> --limit <CHUNK_SIZE> "<SCOPE_QUERY> and not
  tag:proposed-delete and not tag:proposed-archive and not tag:proposed-unsure and not
  tag:proposed-keep"` — each call tags everything it processes, so the exclusion query advances
  deterministically; the sweep of never-classified mail is COMPLETE (terminates when a chunk
  classifies 0).
- **Residual messages** (previously classified, e.g. seen-and-declined in earlier passes): one
  read-only re-emit pass per prior tag, `email-classify --account <account> --emit-tagged
  "<SCOPE_QUERY> and tag:proposed-<X>"` for each of `delete|archive|unsure|keep`. `--emit-tagged`
  derives `proposed_action` strictly from the message's existing `+proposed-<X>` tag (never
  recomputed from the live rule table, never re-tagged — wrapper-contracts.md §12), so a
  residual pass can never silently overwrite a prior human decision even if the classifier's
  rule table has drifted since the tag was applied. It also ignores `--limit`/`MAX_BATCH_SIZE`
  and processes the FULL per-tag match in one call — **the previously-documented completeness
  caveat (residual coverage bounded to `CHUNK_SIZE` per prior-tag bucket) no longer applies**:
  residual coverage is now complete by construction, not an estimate. (At very large per-tag
  bucket sizes — e.g. a fully-tagged multi-thousand-message archive — the per-message
  `notmuch show` read loop this mode uses may take noticeably longer; this is a performance
  characteristic, not a coverage gap.)

`CHUNK_SIZE = 1000` by default (tunable; confirm/adjust after the `--archive` pilot — see the
Pilot Gate section).

### Stage 1 (`--all`): Census + Staleness Gate + Pre-Sweep Estimate

1. Run `email-census --account <account>` for the folder/sender/date overview.
2. **Staleness gate (MANDATORY before an `--all` coverage claim)**: `--all`
   promises whole-mailbox coverage, but `email-classify` only sees what notmuch has indexed. When
   the notmuch index lags the on-disk maildir (no auto-indexer exists — wrapper-contracts.md §13),
   the sweep silently covers only the indexed subset. Parse the census output's
   `INBOX freshness  on-disk=<D>  indexed-files=<F>  divergence=<Δ>  tol=<T>  reindex=<ISO|never>
   [ok|STALE]` line (`email-census`; `on-disk` is himalaya's authoritative
   maildir FILE count, `indexed-files` is a path-prefix post-filtered `notmuch --output=files`
   FILE count for the exact maildir path — a file-vs-file comparison, never a deduped-message
   count; see staleness-detection.md for the full rationale):
   - **`[ok]`** (`Δ ≤ T`, i.e. `divergence <= tol` — a bounded tolerance, NOT strict equality):
     proceed to the count probe (step 3).
   - **`[STALE]`** (`Δ > T`): DO NOT silently proceed — a bucket approval over the indexed
     subset would misrepresent the mailbox. Surface the divergence explicitly (on-disk,
     indexed-files, divergence, tolerance) and route to the **staleness remediation** below (task
     824/827): offer to run the sanctioned reindex `email-reindex`, then re-run census and
     re-check freshness. Only continue the `--all` sweep once freshness reads `[ok]`, OR the user
     explicitly acknowledges partial coverage over the indexed subset for this run.
     - **Reindex marker and autonomous-mode behavior**: use the `reindex=<ISO|never>` field to
       distinguish "reindex never attempted" from "reindex ran, residual persists." In
       interactive mode this only changes the messaging (offer `email-reindex` either way). In
       **autonomous/orchestrator mode** (no human to prompt): if `reindex=never` AND `[STALE]`,
       STOP and report the divergence plus the `email-reindex` command to run — reindex has not
       been attempted this cycle. If `reindex=<ISO>` (recently ran) AND still `[STALE]`, do NOT
       STOP-loop on a repeat reindex; instead report the persistent residual as a candidate
       follow-up (the gap survived a reindex, so re-running it again is unlikely to help) and
       proceed per the interactive-mode acknowledgment path or continue to flag for the user.
   - If the census freshness line is absent (an older `email-census` predating this redesign), emit a
     visible notice that staleness could not be verified and treat coverage as unverified — never
     assume fresh.
3. **Count probe** (wrapper-only count oracle, wrapper-contracts.md §10, §12): run
   `email-classify --account <account> --limit 0 "<SCOPE_QUERY> and not tag:proposed-... (all
   four)"` and parse the `NOTE: query matched <total> message(s)` line for the new-message count
   N (no NOTE line = 0). This new-message probe stays on the mutating `--limit 0` NOTE-line
   oracle — untagged messages carry no `+proposed-*` tag, so there is nothing for a read-only,
   tag-derived mode to read here. It processes nothing and applies no tags, but DOES overwrite
   the candidate manifest — always run this probe before the sweep starts, never between sweep
   chunks.
   Then probe each of the four residual buckets with the read-only mode instead:
   `email-classify --account <account> --emit-tagged "<SCOPE_QUERY> and tag:proposed-<X>"` for
   each of `delete|archive|unsure|keep`, and count the resulting `candidate-manifest.jsonl` line
   count (`wc -l`) as R_delete, R_archive, R_unsure, R_keep respectively. `--emit-tagged` is
   genuinely read-only (no `notmuch tag` call — wrapper-contracts.md §12) and unbounded (no
   `--limit`/`MAX_BATCH_SIZE`), so each residual count is now the COMPLETE per-tag count, not an
   estimate.
4. Present a ONE-TIME estimate before starting:
   `"~N new + R previously-classified messages in scope (R_delete/R_archive/R_unsure/R_keep),
   ~ceil(N/1000) chunks, est. <time> — proceeding in background"`. This is informational, not an
   approval gate (the sweep is read/tag-only); but if N is very large the user can narrow the
   scope here.
5. **"0 new, all residual" status**: if the new-message count probe reports `N=0`, emit before
   Stage 2.5: `"0 new messages; R previously-classified messages across 4 tag buckets
   (R_delete/R_archive/R_unsure/R_keep) — proceeding directly to bucket review"`. In this case
   skip straight to the residual `--emit-tagged` pass (Stage 2 below) and Stage 2.5 — there is
   no new-message sweep to run or wait on.

### Stage 2 (`--all`): Chunked, Backgrounded, Read/Tag-Only Classify Sweep

The ONLY stage in this skill that may run in the background, because it is read/tag-only (the
`email-classify` binary never touches maildir/IMAP state). Start it as a SINGLE backgrounded
Bash job (`run_in_background`) that internally loops over all chunks:

```
ACCUMULATOR="$MANIFEST_DIR/sweep-accumulator-$(date +%Y%m%dT%H%M%S).jsonl"
PROGRESS_LOG="$ACCUMULATOR.progress.log"

# loop (inside ONE backgrounded job):
#   1. email-classify --account <account> --limit 1000 "<SCOPE_QUERY> and not tag:proposed-* (all four)"
#   2. append the candidate manifest to $ACCUMULATOR IMMEDIATELY (the wrapper overwrites
#      candidate-manifest.jsonl on every call — accumulate BEFORE the next chunk)
#   3. append a progress line to $PROGRESS_LOG: "chunk <i>: <count> classified, <running-total> total, <timestamp>"
#   4. stop when a chunk classifies 0 messages
# then one read-only --emit-tagged pass per prior tag (delete/archive/unsure/keep) — unbounded,
# not chunked (no --limit applies, full per-tag match processed in one call) — appending records
# to $ACCUMULATOR with a `"residual": true` field added per line, and logging it as one chunk
# entry in $PROGRESS_LOG.
```

Rules:

- The job invokes ONLY `email-classify` (by name) plus file plumbing (`cat`/`jq`/`cp` on
  manifest files it owns). Never a raw `himalaya`/`notmuch` call.
- Deduplicate the accumulator by `message_id` (last record wins) before Stage 2.5.
- **Crash/resume**: if the job dies mid-sweep, the progress log shows how far it got, and the
  tag-exclusion query makes restart safe — already-classified messages are excluded, so simply
  re-running the sweep loop resumes where it left off (the accumulator keeps prior chunks).
- Monitor the job from the root session; do NOT start Stage 2.5 until the sweep reports
  completion.

### Stage 2.5 (`--all`): Consolidated Bucket Review (mandatory stop, ROOT SESSION)

The single human decision point for the whole mailbox. Runs in the root session via direct
execution — NEVER in the background, NEVER in a subagent.

1. **Bucket construction**: group deduplicated accumulator records by sender domain; for
   freemail/shared domains (gmail.com, yahoo.com, outlook.com, proton.me, etc.) bucket by the
   full sender address instead.
2. **Per-bucket confidence rollup**: roll up with `min()` across member messages per proposed
   action — one weak match must NOT license bulk-approving strong ones.
3. **Option-availability gating (the 0.90 delete gate)**: a bucket receives an
   "approve all as DELETE" option ONLY if its `min()` delete confidence is `>= 0.90`. A
   sub-0.90 bucket's strongest offered options are "approve all as ARCHIVE" / "review
   individually" / "skip". The gate is enforced by WHICH options exist, never by post-hoc
   filtering of an approved set.
4. **Residual labeling**: records with `"residual": true` came from previously-classified
   messages (detectable via their durable `+proposed-*` tags). Present residual buckets
   labeled `[residual]` and new buckets labeled `[new]` so the user can distinguish a fresh
   backlog from previously-seen-and-declined mail; also report the aggregate residual counts
   from Stage 1 (including any residual beyond the bounded re-classify coverage).
5. **Presentation idiom** (reuse `skill-fix-it` Steps 6-7): AskUserQuestion with
   `multiSelect: true`, one option per bucket
   (`label: "<domain> (<count> msgs, <action>, min-conf <c>)"`), and — when there are more
   than 20 buckets — a leading `"Select all (<N> buckets)"` option. Split across successive
   AskUserQuestion calls when the bucket count exceeds one question's option capacity,
   ordered by descending bucket size. Selecting nothing exits gracefully with no mutation.
6. The outcome is a logically-approved set: every message in every approved bucket, with its
   approved action. Messages in unapproved buckets are left untouched (their `+proposed-*`
   tags remain, making them detectable as residual in future runs).

There is NO further approval prompt after this stage: the split/drain stages below are
mechanical and report progress only.

### Stage 3 (`--all`): Materialize the Approved Manifest

Write every approved (message, action) pair from Stage 2.5 into ONE approved manifest file
(JSONL, wrapper schema, git-tracked), e.g.
`$MANIFEST_DIR/approved-all-$(date +%Y%m%dT%H%M%S).jsonl`. This file's mtime IS the approval
time — the `PLAN_EXPIRY_DAYS=7` clock for the entire drain starts here and is never reset.

### Stage 4 (`--all`): Split into ≤50-Per-Action Sub-Manifests

`enforce_batch_size` hard-refuses (never auto-chunks) any manifest with more than 50 lines for
the invoked action (wrapper-contracts.md §5a), so the skill splits BEFORE any execute call:

1. Partition the approved manifest by action (`archive` lines, `delete` lines).
2. Cut each action's lines into groups of ≤ 50, in manifest order.
3. **Pack pairwise**: where both actions have a group at the same index, write the ≤50 archive
   lines AND the ≤50 delete lines into ONE split file — each action independently passes its
   own binary's per-action cap (the caps are per action, per file), halving the file count.
   E.g. 137 archive + 60 delete → splits: (50a+50d), (50a+10d), (37a) = 3 files instead of 5.
4. Name splits predictably: `<approved-manifest-basename>-split-01.jsonl`, `-split-02.jsonl`, …
5. **Preserve the approval mtime on EVERY split**:
   `touch -r <original-approved-file> <split-file>`. Splits must expire exactly when the
   original approval would have — file creation never silently extends the 7-day window.

### Stage 5 (`--all`): Transparent Execute Drain (plan name: Stage 3.5)

Mechanical, progress-only, ROOT SESSION (direct execution — never backgrounded, never a
subagent). The human already decided at Stage 2.5; there is NO per-batch re-prompt.

For each split file, in order:

1. **Expiry pre-check**: if the split's (preserved) mtime is older than `PLAN_EXPIRY_DAYS=7`,
   STOP the drain (see stop-and-report below). The wrapper would refuse anyway; the pre-check
   produces the clean report instead of a wrapper error.
2. Compute `sha256sum <split-file>`.
3. For whichever action(s) the split contains, invoke (same `account` resolved in Stage 0):
   - `email-archive-confirmed --account <account> --execute --confirm-manifest <sha256> --manifest <split-path>`
   - `email-delete-confirmed --account <account> --execute --confirm-manifest <sha256> --manifest <split-path>`
4. **Idempotency**: rely EXCLUSIVELY on the wrapper's per-split `<split>.state.jsonl`
   companion (derived per manifest path, wrapper-contracts.md §4) — already-`executed` IDs are
   skipped by the wrapper on re-run. The skill keeps NO second ledger.
5. Log per split: `split <k>/<K>: <executed> executed, <failed> failed, <skipped> skipped`.
   Individual ID failures (recorded as `failed` in the state file) do NOT stop the drain;
   wrapper-level refusals (hash mismatch, expiry, batch-cap) DO stop it.
6. Note: each split run that executes ≥1 mutation triggers the wrapper's own internal
   `mbsync <account-channel>` reconcile (wrapper-contracts.md §7a; `mbsync gmail` for
   `account=gmail`, `mbsync logos` for `account=logos`) — expect one reconcile per executed
   split; this is frozen wrapper behavior, not something to suppress or replicate.

**Aggregate progress surface** — after each split, report:
`"X of Y splits processed — Z messages executed, W failed; resuming at split #k"` so an
interrupted drain is transparently resumable (re-run the drain; per-split state files skip
completed work).

**Expiry stop-and-report (never re-timestamp)** — on an expired split:

> N remaining approved actions across M un-executed splits have expired (approved {age} days
> ago; limit PLAN_EXPIRY_DAYS=7). Stopping. Re-run `/email --all` to re-sweep and re-review
> the residual.

NEVER `touch` a split (or the approved manifest) to reset its clock after approval; the ONLY
legitimate mtime manipulation is the Stage 4 `touch -r` that back-dates splits to the original
approval time.

### Stage 6 (`--all`): Verify

As in default mode: diff the per-split execution-state files (never re-derived) against the
approved manifest across ALL splits and report totals — executed, failed (with wrapper error
text), skipped-as-already-executed, and expired-unexecuted residual.

### Stage 7 (`--all`): Harvest (opt-in, never-silent)

Same opt-in harvest -> dedup -> tiered-gate -> Bash/jq batch-regen procedure as Default Mode
Stage 7 above (Steps 2-11 apply verbatim: mixed-sender handling, per-key dedup/tally,
evidentiary threshold, archive-scope isolation, the consolidated gate, the Bash/jq write path,
batch index regen, the feedback-loop cap, revocation/edit UX, and success-signal logging). The
ONLY difference is key derivation (Step 1):

1. **Key derivation (`--all` mode)**: key off the Stage 2.5 bucket grouping (domain, or full
   address for freemail/shared domains) as the harvest trigger/start key, cross-referenced
   against the per-split Stage 6 executed totals — only bucket members whose Message-ID is
   confirmed `executed` in some split's state-file diff count as evidence; approved-but-expired
   or failed IDs within an otherwise-executed bucket contribute nothing. Derive each executed
   member's identity the same way as default mode
   (`.claude/scripts/email-preference-harvest.sh identity "<sender>"`) and group by the
   resulting key — the Stage 2.5 bucket is the harvest *trigger*, not itself the memory key; one
   review bucket may fan out into multiple memory-write candidates (design §1.5).

Run this Stage 7 pass once, after Stage 6 totals are final for the whole `--all` sweep (all
splits), not per-split.

---

## Archive Scope (`scope=archive`): Account's Archive Folder with Extra-Caution Gates

`--archive` is an orthogonal scope flag, composable with either mode: it sets `BASE_QUERY` to the
resolved account's archive-folder token (Stage 0) — `folder:Gmail/.All_Mail` for `account=gmail`
(the exact notmuch token, wrapper-contracts.md §11), `folder:Logos/.Archive` for `account=logos`
(the real Proton Archive folder; Logos has no `.All_Mail`/`.Spam` sibling) — as the classify
QUERY base. No wrapper flag exists or is used for folder scoping. Composition:

- `/email --archive` = default 50-step pass stepping through the account's archive folder
  (All Mail for gmail, Archive for logos).
- `/email --all --archive` = full archive-folder sweep + bucket approval + drain.

**Archive of record, per account**:

| Account | Archive folder | Approximate size | Blast-radius note |
|---------|----------------|-------------------|---------------------|
| `gmail` | All Mail (`folder:Gmail/.All_Mail`) | ~64,000 messages | orders of magnitude more destructive than INBOX; much of the content is old mail the classifier's inbox-tuned rules were never validated against |
| `logos` | Archive (`folder:Logos/.Archive`) | ~54 messages (live probe) | much smaller blast radius than the Gmail archive, but the SAME proportionate gates apply in full — smaller scale is not a reason to relax any gate |

Proportionate extra gates apply identically to BOTH accounts (full rationale:
`context/project/email/domain/archive-mode-risk.md`):

1. **Pilot gate first, per account**: before ANY full-scale archive-scope operation FOR A GIVEN
   ACCOUNT, that account's bounded pilot pass must have been run and acknowledged — see "Pilot
   Gate for `--archive`" below. Check this gate at Stage 0, before any sweep or classify call,
   keyed to the resolved `account`.
2. **Second, distinctly-worded blast-radius confirmation**: after the normal review gate
   (Stage 3 review in default mode / Stage 2.5 bucket approval in `--all` mode) and BEFORE
   Stage 4/execute, ask a separate AskUserQuestion that names the blast radius explicitly:
   "Yes, operate on N archived messages in All Mail" (gmail) / "Yes, operate on N archived
   messages in Logos Archive" (logos) / "No, stop here". Generic "proceed?" wording is not
   acceptable; the option label must state N and the account's archive-folder name.
3. **Asymmetric, corroborated confidence bar for deletes**: archive-scope DELETE bulk-approval
   requires more than the inbox 0.90 gate — the bucket must be BOTH `min()`-confidence
   `>= 0.90` AND corroborated by a deterministic rule-tier reason (e.g.
   `custom-domain-delete:*` at 0.98); keyword-fallback-only reasons never license an
   archive-scope bulk delete option, regardless of numeric confidence. Archive proposals
   (recoverable moves) keep the standard bar. When in doubt, offer archive-in-place/skip, not
   delete. This bar is identical for both accounts.
4. **Reversible-then-hard two-phase**: archive-scope deletes ALWAYS stop at the recoverable
   hop (move to Trash). `--expunge-trash` is opt-in ONLY — a separate, explicit user request
   in a later invocation, with its own `--execute --confirm-manifest` gate (the wrapper
   already tracks the two hops in separate state files). Never expunge by default; never
   bundle move-to-Trash and expunge in one pass.
5. **Per-chunk classify** (inherited from `--all` Stage 2): never attempt a single unchunked
   classify over the archive folder, for either account.
6. **Never auto-chain `/email --sync`** after an archive drain. The wrapper's internal
   per-run `mbsync <account-channel>` reconcile is frozen contract behavior; the separate
   `--sync` skill run is a human decision for a later, calmer moment.

## Pilot Gate for `--archive`

Because this extension has never been validated against a live mailbox at archive scale, the
FIRST archive-scope operation **for a given account** must be a bounded pilot, and full-scale
`--archive` for that account is REFUSED until that account's pilot has been run and explicitly
acknowledged. **The gate is scoped per account**: a Gmail pilot-ack does NOT license a Logos
archive-scope run, and vice versa — each account's first full-scale archive pass must clear its
own pilot independently.

- **Pilot bound**: scope the pilot to a slice of the account's archive folder capped at low
  thousands — for `account=gmail`, e.g. one historical year
  (`folder:Gmail/.All_Mail and date:2019-01-01..2019-12-31`, picking a year whose census count
  is ≲ 3,000); for `account=logos`, the entire `folder:Logos/.Archive` folder is already only
  ~54 messages (live probe), so the "pilot" and the full archive coincide — no further date
  slicing is needed before acknowledging it. In default mode, simply the ordinary 50-step pass
  also qualifies as a pilot for either account. The pilot runs the FULL flow (sweep → buckets →
  approval → split → drain → verify) at reduced blast radius.
- **Acknowledgement record, keyed by account**: after a pilot completes and Stage 6
  verification is clean, ask the user to acknowledge the pilot outcome (AskUserQuestion, root
  session). On acknowledgement write (or update) `$MANIFEST_DIR/archive-pilot-ack.json` — always
  this single, hardcoded file path; per-account filenames (e.g. `archive-pilot-ack-gmail.json` /
  `archive-pilot-ack-logos.json`) are never used. The file holds a JSON array of per-account
  records, each shaped exactly as: `{"account": "gmail|logos", "acknowledged_at": "<ISO8601>",
  "pilot_scope": "<query>", "messages_processed": N, "chunk_size_verdict": "keep 1000 | adjust to
  <n>"}`. If the file does not yet 

…(truncated)
