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)
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:
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.
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.
Focus terms: translate focus_hint (if any) into additional notmuch query terms
(e.g. from:github.com) appended to BASE_QUERY with and.
Branch on mode: mode=default -> Default Mode flow below; mode=all -> --all Mode
flow below.
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.
- 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.
- 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.
- 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: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".)
- 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.
- 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.
- 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.
- 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)).
- 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).
- 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.
- 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.
- 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
- Run
email-census --account <account> for the folder/sender/date overview.
- 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.
- 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.
- 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.
- "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.
- 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.
- Per-bucket confidence rollup: roll up with
min() across member messages per proposed
action — one weak match must NOT license bulk-approving strong ones.
- 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.
- 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).
- 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.
- 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:
- Partition the approved manifest by action (
archive lines, delete lines).
- Cut each action's lines into groups of ≤ 50, in manifest order.
- 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.
- Name splits predictably:
<approved-manifest-basename>-split-01.jsonl, -split-02.jsonl, …
- 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:
- 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.
- Compute
sha256sum <split-file>.
- 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>
- 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.
- 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.
- 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):
- 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):
- 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.
- 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.
- 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.
- 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.
- Per-chunk classify (inherited from
--all Stage 2): never attempt a single unchunked
classify over the archive folder, for either account.
- 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)
1---2name: skill-email-cleanup3description: 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.4---56# Email Cleanup Skill (Direct Execution)78Direct-execution skill for email triage, invoked by `/email`. Runs a9census -> classify -> review -> confirmed-execute pass without dispatching to a subagent. This10skill is wrapper-only: it may invoke ONLY the five named nix-built binaries below, by name, and11must NEVER call raw `himalaya`, `notmuch`, `msmtp`, or `secret-tool`, and must NEVER run `rm`12against a Maildir path. The one sanctioned exception is the index-only `email-reindex` operator13helper (staleness remediation) — non-mutating, exempt exactly as the `mbsync` reconcile14is; see the Staleness Remediation section.1516An account selector, two decision-granularity modes, and an orthogonal folder scope (parsed by17`/email`, passed in as args):1819| Arg | Values | Meaning |20|-----|--------|---------|21| `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. |22| `mode` | `default` (no flag) / `all` (`--all`) | bounded 50-step pass vs. whole-mailbox sweep + one bucket approval + sub-50 drain |23| `scope` | `inbox` (no flag) / `archive` (`--archive`) | classify QUERY base — account-dependent, see Stage 0 |24| `focus_hint` | free text | optional extra narrowing (sender/domain/topic) |2526**MANDATORY INTERACTIVE REQUIREMENT -- DO NOT SKIP**:27- The human review gate is NEVER skipped in any mode. In `mode=default` it is Stage 3 (per-message28 candidate review). In `mode=all` it is Stage 2.5 (one consolidated bucket approval).29- Do NOT construct or pass `--execute --confirm-manifest <sha256>` to any binary until the user30 has explicitly approved the reviewed candidate set in this conversation.31- The review-gate AskUserQuestion and ALL execute calls run in the ROOT SESSION via direct32 execution — never inside a background job or subagent. The ONLY thing that may ever run in the33 background is the read/tag-only classify sweep of `mode=all` Stage 2.3435## $PATH Precondition (contract §9 — check before Stage 1)3637```bash38command -v email-census email-classify email-archive-confirmed email-delete-confirmed \39 email-unsubscribe-extract40```4142If any binary is missing, stop and tell the user to run `home-manager switch --flake .#<user>`43to activate the generation containing `modules/home/email/agent-tools.nix`. Do not fall back to44a raw `himalaya`/`notmuch` call.4546## Account Liveness Check (`account=logos` only — check before Stage 1)4748The five wrapper binaries accept `--account <gmail|logos>` as a live enum (wrapper-contracts.md49§2, verified 9/9 against the `.dotfiles` wrapper suite); unknown values are rejected loudly. This is not a50permanent gate on `logos` — it is a light read-only liveness check that guards against a51transient/environmental problem (e.g. a stale `$PATH` generation predating multi-account52support). When `account=logos` is resolved (from `/email`'s `--account logos`/`--logos`), this53skill confirms the wrapper accepts it BEFORE any binary call by running `email-census --account54logos` (read-only, side-effect-free per the Five Binaries safety-class table below) and checking55its exit code and stderr — NOT `email-census --account logos --help`, which short-circuits56before flag validation (wrapper-contracts.md line 41: `--help` prints verb/safety-class/flags57unconditionally) and would never actually test account acceptance. If the probe exits non-zero58or reports an account-rejection error, STOP immediately and report:5960> `/email --logos` failed its liveness check — `email-census --account logos` did not succeed.61> Run `home-manager switch --flake .#<user>` to activate the generation with multi-account62> support, then retry.6364This is an ACTIONABLE, LOUD failure — never a silent continuation against `gmail`. Do not65construct or run any `folder:Logos*` query, and do not touch Gmail either, if the liveness check66fails. `account=gmail` is entirely unaffected by this check (it is today's already-accepted67default value) and proceeds straight to the `$PATH` check above.6869## The Five Wrapper Binaries (the ONLY binaries this skill may invoke)7071| Binary | Safety class | Mutates? |72|--------|--------------|----------|73| `email-census` | read-only | no |74| `email-classify` | local-tags-only (notmuch tags, never maildir/IMAP) | no (tags only) |75| `email-unsubscribe-extract` | read-only | no |76| `email-archive-confirmed` | mutation | yes (maildir move) |77| `email-delete-confirmed` | mutation | yes (maildir move + optional `--expunge-trash`) |7879## Execution Flow8081### Stage 0: Mode and Scope Dispatch8283Resolve the branch before any binary call:84850. **Account liveness check**: if `account=logos`, run the Account Liveness Check above FIRST;86 do not proceed to step 1 until it passes. `account` is resolved exactly ONCE here and threaded87 unchanged (same value) to every wrapper call, the pilot gate, and both `BASE_QUERY` branches88 below for the rest of this invocation — never re-resolved mid-flow.891. **Base query from account + scope** (folder: tokens ONLY — never `tag:<account>`, which is90 live but deliberately not relied on for wrapper scoping; no fallthrough to a91 Gmail token for a non-gmail account):9293 | `account` | `scope=inbox` | `scope=archive` |94 |-----------|---------------|------------------|95 | `gmail` (default) | `BASE_QUERY="folder:Gmail"` | `BASE_QUERY="folder:Gmail/.All_Mail"` |96 | `logos` | `BASE_QUERY="folder:Logos"` (bare root = INBOX) | `BASE_QUERY="folder:Logos/.Archive"` (the real Proton Archive folder — Logos has no `.All_Mail`/`.Spam`) |9798 These are the exact tokens from wrapper-contracts.md §11 (gmail) and the Logos ground-truth99 folder census — folder scoping is ONLY ever expressed as (part of) the100 `email-classify` QUERY positional; no wrapper flag exists for it.101102 The verified `folder:`-syntax forms (live-verified; see103 `domain/index-architecture.md`):104105 | Form | Example | Live behavior |106 |------|---------|---------------|107 | Glob (broken, never in wrapper source) | `folder:Gmail*` | 0 matches — notmuch `folder:` does not glob |108 | Bare exact-match (the `/email` wrappers, by design) | `folder:Gmail` | INBOX-only exact maildir-folder match |109 | Regex (aerc querymap) | `folder:/Gmail/` | Whole-account match across all folders |110111 A `tag:<account>` scheme (`tag:gmail`/`tag:logos`) is live (populated by the112 `postNew` hook, exactly matching `folder:/Gmail/` / `folder:/Logos/`) but wrapper scoping113 deliberately never relies on it.1142. **Focus terms**: translate `focus_hint` (if any) into additional notmuch query terms115 (e.g. `from:github.com`) appended to `BASE_QUERY` with `and`.1163. **Branch on mode**: `mode=default` -> Default Mode flow below; `mode=all` -> `--all` Mode117 flow below.1184. **Archive gates**: if `scope=archive`, apply the extra-caution gates in the119 "Archive Scope (`scope=archive`)" section to whichever mode flow runs, including the120 PER-ACCOUNT pilot gate check BEFORE any sweep or classify pass.121122---123124## Default Mode (`mode=default`): Bounded 50-Step Pass125126The safer default. Each pass classifies at most 50 messages, is reviewed per-message, and by127construction can never trip the wrapper's `enforce_batch_size` cap — so this mode never needs128classify chunking (input is bounded to 50) and never needs manifest splitting (the approved set129is ≤ 50 per action). Repeated bare `/email` runs make real forward progress via the cursor rule130in Stage 2.131132### Stage 1: Census133134Run `email-census --account <account>` (dry-run/read-only by nature) to summarize senders,135folders, and date ranges. Present a brief summary to the user.136137### Stage 2: Classify (with cross-invocation cursor)138139Run `email-classify --account <account> --limit 50 "<CURSOR_QUERY>"` to produce a candidate140manifest (JSONL keyed on Message-ID) with `proposed_action` (`delete|archive|keep|unsure`) and141`confidence` per message, following the recall-on-keep-bias standard (near-100% recall on142`keep`; delete auto-proposed only at confidence `>= 0.90`, otherwise `unsure`).143144**Cursor rule** — once a mailbox has had at least one default pass, exclude already-classified145messages so each re-run advances by construction:146147```148CURSOR_QUERY = <BASE_QUERY + focus terms> and not tag:proposed-delete149 and not tag:proposed-archive and not tag:proposed-unsure150 and not tag:proposed-keep151```152153Why this advances: `email-classify` tags every message it processes with exactly one durable154`+proposed-*` notmuch tag (wrapper-contracts.md §10). Executed messages leave the folder155entirely; declined-but-seen messages keep their durable tag and are excluded by the cursor. So156a second bare `/email` classifies a different 50 than the first, and successive runs step157through the mailbox oldest-remaining-first (newest-first within each pass). The cursor is158expressed purely as an `email-classify` QUERY argument — the skill never issues a raw159`notmuch tag`/`notmuch search` command. On a fresh mailbox (no `+proposed-*` tags), the cursor160query is equivalent to the plain scoped query.161162To deliberately revisit previously-declined messages, use `/email --all` (the whole-mailbox163sweep re-surfaces them); the default cursor intentionally skips them.164165**Cursor rule stays account-agnostic by construction**: `CURSOR_QUERY` is built on top of the166now-account-aware `BASE_QUERY` (Stage 0), which already confines every account to its own167disjoint folder subtree (exact-match `folder:Gmail`/`folder:Gmail/.<Sub>` vs168`folder:Logos`/`folder:Logos/.<Sub>` tokens — notmuch `folder:` does not glob). The169`+proposed-*` tags themselves are170per-message notmuch tags, not account-scoped, but because the two accounts' folders never171overlap, a Gmail pass and a Logos pass can never tag, cursor-exclude, or collide on the same172message — no additional account qualifier is needed in the tag-exclusion terms.173174### Stage 3: Review (mandatory stop)175176Present the candidate manifest to the user via AskUserQuestion. Allow the user to approve some,177all, or none of the proposed actions. Do not proceed without an explicit response.178179### Stage 4: Confirm180181Once the user approves a set of actions, an approved manifest (git-tracked) is produced/updated182and its sha256 (over the raw manifest bytes) is computed.183184### Stage 5: Execute185186Invoke `email-archive-confirmed --account <account>` and/or `email-delete-confirmed --account187<account>` with `--execute --confirm-manifest <sha256>` for the approved manifest only — the188SAME `account` resolved in Stage 0, never re-resolved. Optionally run189`email-unsubscribe-extract --account <account>` (read-only) to surface `List-Unsubscribe`190candidates for senders the user flagged.191192### Stage 6: Verify193194Diff the wrapper's own execution-state output (never re-derived) against the approved manifest195to confirm which IDs were actually mutated. Report this diff to the user.196197### Stage 7: Harvest (opt-in, never-silent)198199An opt-in step that routes wrapper-confirmed decisions from this pass into the memory vault as200sender/domain-aggregated `email/preferences/{account}/{key}` preference memories. Grounded in201the authoritative design at202`context/project/email/design/email-to-memory-preferences.md` — see that document203for the full rationale; this section is the executable prose. Fires ONLY after Stage 6, reading204ONLY the Stage 6 *executed* diff — never the Stage 2 candidate manifest (`proposed_action`,205unconfirmed) and never an approved-but-not-yet-executed manifest.2062071. **Key derivation (default mode)**: for every Message-ID the Stage 6 diff confirms as208 `executed` this pass, look up its `sender` field in the approved manifest and derive its209 identity via `.claude/scripts/email-preference-harvest.sh identity "<sender>"` (add210 `--rollup` only for a caller-chosen non-freemail domain rollup — never for freemail/shared211 domains). Group executed IDs by the resulting `.key`, prefixed by the resolved account:212 `email/preferences/${ACCOUNT}/${KEY}`. Never key on Stage 2/3/4 unconfirmed lines.2132. **Mixed-sender handling (design §1.5)** is a first-class branch, not an edge case: when a214 group's confirmed actions this pass are heterogeneous with no uniform majority, either split215 by a subject/category token into distinct keys, or decline to aggregate that portion this216 pass (leave it as unresolved tally noise) — never average into a false scalar action.2173. **Per-key dedup + tally update**: for each key's group, determine this round's action-count218 breakdown (e.g. `{archive: 6}` for a uniform group, or split per mixed-sender handling), then:219 ```bash220 EXISTING=$(bash .claude/scripts/email-preference-harvest.sh dedup .memory/memory-index.json \221 "email/preferences/${ACCOUNT}/${KEY}")222 EXISTING_TALLY=$(echo "$EXISTING" | jq -c '.tally // null' 2>/dev/null || echo null)223 RESULT=$(bash .claude/scripts/email-preference-harvest.sh tally-op "$EXISTING_TALLY" \224 "$ACTION" "$COUNT" "$(date -u +%Y-%m-%d)")225 OPERATION=$(echo "$RESULT" | jq -r '.operation') # CREATE | EXTEND | UPDATE226 NEW_TALLY=$(echo "$RESULT" | jq -c '.tally')227 ```228 (Note: a real memory file's tally block, not `.tally` on the raw index entry, is the ground229 truth — read it from the memory file body if the index entry alone is insufficient; the230 `dedup` subcommand's index lookup is for the exact-key hit/miss decision, per231 `skill-learn/SKILL.md`'s "Exact-Key Dedup for Reserved Namespaces".)2324. **Evidentiary threshold gate**: only keys/operations meeting233 `.claude/scripts/email-preference-harvest.sh threshold "$NEW_TALLY" "$UNIFORM"` (uniform-batch234 this round, or rolling N>=3 at >=80% against the post-update tally) become Tier 1 candidates235 below; a single isolated confirm below both bars is still tallied (Step 3) but is not236 presented as a strong preference this round.2375. **Archive-scope isolation**: if this pass's scope is `scope=archive`, record the tally delta238 in the memory's `### Archive-scope tally` sub-section (per `skill-learn/SKILL.md`'s239 Namespace-Scoped Tally-Arithmetic subsection) instead of the inbox-scope tally — never merge240 the two.2416. **One consolidated, never-silent gate** (`AskUserQuestion`, root session), mirroring242 `skill-todo`'s harvest -> dedup -> tiered-gate -> batch-regen *logic only* (never its243 `state.json`/`project_number` substrate):244 - **Tier 1** (pre-selected): keys meeting the evidentiary threshold outright this round (Step245 4).246 - **Tier 2** (shown, not pre-selected): keys newly crossing the rolling-N threshold this247 round.248 - **Fuzzy near-miss suggestions** (skill-learn's retained fuzzy path, §4.2): surfaced as a249 labeled option, never auto-selected.250 - Skip the gate entirely (no prompt) if this pass produced zero candidates in any tier —251 harvest is opt-in per-round, not a mandatory stop when there is nothing to offer.2527. **Write on confirm — Bash/jq file-write path** (this skill's `allowed-tools` is `Bash, Read,253 AskUserQuestion`; memory files are written via Bash heredoc/`jq`, never via a `Write`/`Edit`254 tool call or a `skill-learn`/`/learn` dispatch): for each user-confirmed key, write or update255 `.memory/10-Memories/MEM-email-pref-{key-slug}.md` using the body template from256 `skill-learn/SKILL.md`'s Namespace-Scoped Tally-Arithmetic subsection (frontmatter incl.257 `topic: "email/preferences/${ACCOUNT}/${KEY}"`, `category: preference`; CREATE writes the258 full template, EXTEND appends a dated `## History` line, UPDATE additionally moves the prior259 summary line to `## History` marked `(superseded)`).2608. **Batch index regeneration** (design §4.4): regenerate `.memory/memory-index.json` **once**261 after the entire harvest round (all confirmed keys in one gate response), never per262 individual CREATE/UPDATE/EXTEND — independent of the `--clean` flag (`--clean` only suppresses263 `/research`/`/plan`/`/implement` auto-retrieval; it has no relationship to this write-side264 gate).2659. **Feedback-loop cap (design §5.3, MUST NOT)**: the harvest never mutates `proposed_action` or266 raises `confidence` on any future classify pass — it may only ever contribute as a surfaced267 tally at a future gate/read-back. The vault stays strictly advisory relative to the frozen268 classifier.26910. **Revocation/edit UX (design §5.5)**: a user-invoked "forget this preference" option, offered270 alongside the harvest gate or on request, reuses the existing tombstone pattern271 (`status: tombstoned`, `tombstoned_at`, `tombstone_reason: "user_revoked"`) or a tally reset —272 distinct from `/distill --purge` (automatic, staleness-driven) and never automatic.27311. **Minimal success-signal logging (design §5.5)**: for keys with a pre-existing memory prior274 to this round, log a per-round agreement rate (confirmed action == the memory's pre-round275 derived dominant action) as one harvest log line — no new dashboard, enough visibility for a276 future audit to detect drift.277278---279280## `--all` Mode (`mode=all`): Whole-Mailbox Sweep, One Bucket Approval, Sub-50 Drain281282The whole-mailbox operation. The wrapper's 50-cap disappears from the user's experience: the283classify sweep is unconditionally chunked, the human decision is ONE consolidated sender/domain284bucket approval, and execution is a mechanical drain of ≤50-per-action sub-manifests with285progress-only reporting. Never raise `MAX_BATCH_SIZE` — this mode exists precisely so that286looping in ≤50 splits replaces any temptation to touch the frozen wrapper.287288**Pagination mechanism (ground-truth adapted)**: the round-2 design called for capturing the289in-scope message-ID list once and slicing it into `id:a or id:b ...` chunk queries. Phase-1290verification (wrapper-contracts.md §10) found that NO wrapper emits a complete message-ID list291for an arbitrary query, and capturing one any other way would require a raw `notmuch search` —292forbidden. Per the plan's pre-authorized fallback, the sweep therefore paginates with293`--limit`-based chunking driven by the QUERY positional:294295- **New messages** (never classified): repeated296 `email-classify --account <account> --limit <CHUNK_SIZE> "<SCOPE_QUERY> and not297 tag:proposed-delete and not tag:proposed-archive and not tag:proposed-unsure and not298 tag:proposed-keep"` — each call tags everything it processes, so the exclusion query advances299 deterministically; the sweep of never-classified mail is COMPLETE (terminates when a chunk300 classifies 0).301- **Residual messages** (previously classified, e.g. seen-and-declined in earlier passes): one302 read-only re-emit pass per prior tag, `email-classify --account <account> --emit-tagged303 "<SCOPE_QUERY> and tag:proposed-<X>"` for each of `delete|archive|unsure|keep`. `--emit-tagged`304 derives `proposed_action` strictly from the message's existing `+proposed-<X>` tag (never305 recomputed from the live rule table, never re-tagged — wrapper-contracts.md §12), so a306 residual pass can never silently overwrite a prior human decision even if the classifier's307 rule table has drifted since the tag was applied. It also ignores `--limit`/`MAX_BATCH_SIZE`308 and processes the FULL per-tag match in one call — **the previously-documented completeness309 caveat (residual coverage bounded to `CHUNK_SIZE` per prior-tag bucket) no longer applies**:310 residual coverage is now complete by construction, not an estimate. (At very large per-tag311 bucket sizes — e.g. a fully-tagged multi-thousand-message archive — the per-message312 `notmuch show` read loop this mode uses may take noticeably longer; this is a performance313 characteristic, not a coverage gap.)314315`CHUNK_SIZE = 1000` by default (tunable; confirm/adjust after the `--archive` pilot — see the316Pilot Gate section).317318### Stage 1 (`--all`): Census + Staleness Gate + Pre-Sweep Estimate3193201. Run `email-census --account <account>` for the folder/sender/date overview.3212. **Staleness gate (MANDATORY before an `--all` coverage claim)**: `--all`322 promises whole-mailbox coverage, but `email-classify` only sees what notmuch has indexed. When323 the notmuch index lags the on-disk maildir (no auto-indexer exists — wrapper-contracts.md §13),324 the sweep silently covers only the indexed subset. Parse the census output's325 `INBOX freshness on-disk=<D> indexed-files=<F> divergence=<Δ> tol=<T> reindex=<ISO|never>326 [ok|STALE]` line (`email-census`; `on-disk` is himalaya's authoritative327 maildir FILE count, `indexed-files` is a path-prefix post-filtered `notmuch --output=files`328 FILE count for the exact maildir path — a file-vs-file comparison, never a deduped-message329 count; see staleness-detection.md for the full rationale):330 - **`[ok]`** (`Δ ≤ T`, i.e. `divergence <= tol` — a bounded tolerance, NOT strict equality):331 proceed to the count probe (step 3).332 - **`[STALE]`** (`Δ > T`): DO NOT silently proceed — a bucket approval over the indexed333 subset would misrepresent the mailbox. Surface the divergence explicitly (on-disk,334 indexed-files, divergence, tolerance) and route to the **staleness remediation** below (task335 824/827): offer to run the sanctioned reindex `email-reindex`, then re-run census and336 re-check freshness. Only continue the `--all` sweep once freshness reads `[ok]`, OR the user337 explicitly acknowledges partial coverage over the indexed subset for this run.338 - **Reindex marker and autonomous-mode behavior**: use the `reindex=<ISO|never>` field to339 distinguish "reindex never attempted" from "reindex ran, residual persists." In340 interactive mode this only changes the messaging (offer `email-reindex` either way). In341 **autonomous/orchestrator mode** (no human to prompt): if `reindex=never` AND `[STALE]`,342 STOP and report the divergence plus the `email-reindex` command to run — reindex has not343 been attempted this cycle. If `reindex=<ISO>` (recently ran) AND still `[STALE]`, do NOT344 STOP-loop on a repeat reindex; instead report the persistent residual as a candidate345 follow-up (the gap survived a reindex, so re-running it again is unlikely to help) and346 proceed per the interactive-mode acknowledgment path or continue to flag for the user.347 - If the census freshness line is absent (an older `email-census` predating this redesign), emit a348 visible notice that staleness could not be verified and treat coverage as unverified — never349 assume fresh.3503. **Count probe** (wrapper-only count oracle, wrapper-contracts.md §10, §12): run351 `email-classify --account <account> --limit 0 "<SCOPE_QUERY> and not tag:proposed-... (all352 four)"` and parse the `NOTE: query matched <total> message(s)` line for the new-message count353 N (no NOTE line = 0). This new-message probe stays on the mutating `--limit 0` NOTE-line354 oracle — untagged messages carry no `+proposed-*` tag, so there is nothing for a read-only,355 tag-derived mode to read here. It processes nothing and applies no tags, but DOES overwrite356 the candidate manifest — always run this probe before the sweep starts, never between sweep357 chunks.358 Then probe each of the four residual buckets with the read-only mode instead:359 `email-classify --account <account> --emit-tagged "<SCOPE_QUERY> and tag:proposed-<X>"` for360 each of `delete|archive|unsure|keep`, and count the resulting `candidate-manifest.jsonl` line361 count (`wc -l`) as R_delete, R_archive, R_unsure, R_keep respectively. `--emit-tagged` is362 genuinely read-only (no `notmuch tag` call — wrapper-contracts.md §12) and unbounded (no363 `--limit`/`MAX_BATCH_SIZE`), so each residual count is now the COMPLETE per-tag count, not an364 estimate.3654. Present a ONE-TIME estimate before starting:366 `"~N new + R previously-classified messages in scope (R_delete/R_archive/R_unsure/R_keep),367 ~ceil(N/1000) chunks, est. <time> — proceeding in background"`. This is informational, not an368 approval gate (the sweep is read/tag-only); but if N is very large the user can narrow the369 scope here.3705. **"0 new, all residual" status**: if the new-message count probe reports `N=0`, emit before371 Stage 2.5: `"0 new messages; R previously-classified messages across 4 tag buckets372 (R_delete/R_archive/R_unsure/R_keep) — proceeding directly to bucket review"`. In this case373 skip straight to the residual `--emit-tagged` pass (Stage 2 below) and Stage 2.5 — there is374 no new-message sweep to run or wait on.375376### Stage 2 (`--all`): Chunked, Backgrounded, Read/Tag-Only Classify Sweep377378The ONLY stage in this skill that may run in the background, because it is read/tag-only (the379`email-classify` binary never touches maildir/IMAP state). Start it as a SINGLE backgrounded380Bash job (`run_in_background`) that internally loops over all chunks:381382```383ACCUMULATOR="$MANIFEST_DIR/sweep-accumulator-$(date +%Y%m%dT%H%M%S).jsonl"384PROGRESS_LOG="$ACCUMULATOR.progress.log"385386# loop (inside ONE backgrounded job):387# 1. email-classify --account <account> --limit 1000 "<SCOPE_QUERY> and not tag:proposed-* (all four)"388# 2. append the candidate manifest to $ACCUMULATOR IMMEDIATELY (the wrapper overwrites389# candidate-manifest.jsonl on every call — accumulate BEFORE the next chunk)390# 3. append a progress line to $PROGRESS_LOG: "chunk <i>: <count> classified, <running-total> total, <timestamp>"391# 4. stop when a chunk classifies 0 messages392# then one read-only --emit-tagged pass per prior tag (delete/archive/unsure/keep) — unbounded,393# not chunked (no --limit applies, full per-tag match processed in one call) — appending records394# to $ACCUMULATOR with a `"residual": true` field added per line, and logging it as one chunk395# entry in $PROGRESS_LOG.396```397398Rules:399400- The job invokes ONLY `email-classify` (by name) plus file plumbing (`cat`/`jq`/`cp` on401 manifest files it owns). Never a raw `himalaya`/`notmuch` call.402- Deduplicate the accumulator by `message_id` (last record wins) before Stage 2.5.403- **Crash/resume**: if the job dies mid-sweep, the progress log shows how far it got, and the404 tag-exclusion query makes restart safe — already-classified messages are excluded, so simply405 re-running the sweep loop resumes where it left off (the accumulator keeps prior chunks).406- Monitor the job from the root session; do NOT start Stage 2.5 until the sweep reports407 completion.408409### Stage 2.5 (`--all`): Consolidated Bucket Review (mandatory stop, ROOT SESSION)410411The single human decision point for the whole mailbox. Runs in the root session via direct412execution — NEVER in the background, NEVER in a subagent.4134141. **Bucket construction**: group deduplicated accumulator records by sender domain; for415 freemail/shared domains (gmail.com, yahoo.com, outlook.com, proton.me, etc.) bucket by the416 full sender address instead.4172. **Per-bucket confidence rollup**: roll up with `min()` across member messages per proposed418 action — one weak match must NOT license bulk-approving strong ones.4193. **Option-availability gating (the 0.90 delete gate)**: a bucket receives an420 "approve all as DELETE" option ONLY if its `min()` delete confidence is `>= 0.90`. A421 sub-0.90 bucket's strongest offered options are "approve all as ARCHIVE" / "review422 individually" / "skip". The gate is enforced by WHICH options exist, never by post-hoc423 filtering of an approved set.4244. **Residual labeling**: records with `"residual": true` came from previously-classified425 messages (detectable via their durable `+proposed-*` tags). Present residual buckets426 labeled `[residual]` and new buckets labeled `[new]` so the user can distinguish a fresh427 backlog from previously-seen-and-declined mail; also report the aggregate residual counts428 from Stage 1 (including any residual beyond the bounded re-classify coverage).4295. **Presentation idiom** (reuse `skill-fix-it` Steps 6-7): AskUserQuestion with430 `multiSelect: true`, one option per bucket431 (`label: "<domain> (<count> msgs, <action>, min-conf <c>)"`), and — when there are more432 than 20 buckets — a leading `"Select all (<N> buckets)"` option. Split across successive433 AskUserQuestion calls when the bucket count exceeds one question's option capacity,434 ordered by descending bucket size. Selecting nothing exits gracefully with no mutation.4356. The outcome is a logically-approved set: every message in every approved bucket, with its436 approved action. Messages in unapproved buckets are left untouched (their `+proposed-*`437 tags remain, making them detectable as residual in future runs).438439There is NO further approval prompt after this stage: the split/drain stages below are440mechanical and report progress only.441442### Stage 3 (`--all`): Materialize the Approved Manifest443444Write every approved (message, action) pair from Stage 2.5 into ONE approved manifest file445(JSONL, wrapper schema, git-tracked), e.g.446`$MANIFEST_DIR/approved-all-$(date +%Y%m%dT%H%M%S).jsonl`. This file's mtime IS the approval447time — the `PLAN_EXPIRY_DAYS=7` clock for the entire drain starts here and is never reset.448449### Stage 4 (`--all`): Split into ≤50-Per-Action Sub-Manifests450451`enforce_batch_size` hard-refuses (never auto-chunks) any manifest with more than 50 lines for452the invoked action (wrapper-contracts.md §5a), so the skill splits BEFORE any execute call:4534541. Partition the approved manifest by action (`archive` lines, `delete` lines).4552. Cut each action's lines into groups of ≤ 50, in manifest order.4563. **Pack pairwise**: where both actions have a group at the same index, write the ≤50 archive457 lines AND the ≤50 delete lines into ONE split file — each action independently passes its458 own binary's per-action cap (the caps are per action, per file), halving the file count.459 E.g. 137 archive + 60 delete → splits: (50a+50d), (50a+10d), (37a) = 3 files instead of 5.4604. Name splits predictably: `<approved-manifest-basename>-split-01.jsonl`, `-split-02.jsonl`, …4615. **Preserve the approval mtime on EVERY split**:462 `touch -r <original-approved-file> <split-file>`. Splits must expire exactly when the463 original approval would have — file creation never silently extends the 7-day window.464465### Stage 5 (`--all`): Transparent Execute Drain (plan name: Stage 3.5)466467Mechanical, progress-only, ROOT SESSION (direct execution — never backgrounded, never a468subagent). The human already decided at Stage 2.5; there is NO per-batch re-prompt.469470For each split file, in order:4714721. **Expiry pre-check**: if the split's (preserved) mtime is older than `PLAN_EXPIRY_DAYS=7`,473 STOP the drain (see stop-and-report below). The wrapper would refuse anyway; the pre-check474 produces the clean report instead of a wrapper error.4752. Compute `sha256sum <split-file>`.4763. For whichever action(s) the split contains, invoke (same `account` resolved in Stage 0):477 - `email-archive-confirmed --account <account> --execute --confirm-manifest <sha256> --manifest <split-path>`478 - `email-delete-confirmed --account <account> --execute --confirm-manifest <sha256> --manifest <split-path>`4794. **Idempotency**: rely EXCLUSIVELY on the wrapper's per-split `<split>.state.jsonl`480 companion (derived per manifest path, wrapper-contracts.md §4) — already-`executed` IDs are481 skipped by the wrapper on re-run. The skill keeps NO second ledger.4825. Log per split: `split <k>/<K>: <executed> executed, <failed> failed, <skipped> skipped`.483 Individual ID failures (recorded as `failed` in the state file) do NOT stop the drain;484 wrapper-level refusals (hash mismatch, expiry, batch-cap) DO stop it.4856. Note: each split run that executes ≥1 mutation triggers the wrapper's own internal486 `mbsync <account-channel>` reconcile (wrapper-contracts.md §7a; `mbsync gmail` for487 `account=gmail`, `mbsync logos` for `account=logos`) — expect one reconcile per executed488 split; this is frozen wrapper behavior, not something to suppress or replicate.489490**Aggregate progress surface** — after each split, report:491`"X of Y splits processed — Z messages executed, W failed; resuming at split #k"` so an492interrupted drain is transparently resumable (re-run the drain; per-split state files skip493completed work).494495**Expiry stop-and-report (never re-timestamp)** — on an expired split:496497> N remaining approved actions across M un-executed splits have expired (approved {age} days498> ago; limit PLAN_EXPIRY_DAYS=7). Stopping. Re-run `/email --all` to re-sweep and re-review499> the residual.500501NEVER `touch` a split (or the approved manifest) to reset its clock after approval; the ONLY502legitimate mtime manipulation is the Stage 4 `touch -r` that back-dates splits to the original503approval time.504505### Stage 6 (`--all`): Verify506507As in default mode: diff the per-split execution-state files (never re-derived) against the508approved manifest across ALL splits and report totals — executed, failed (with wrapper error509text), skipped-as-already-executed, and expired-unexecuted residual.510511### Stage 7 (`--all`): Harvest (opt-in, never-silent)512513Same opt-in harvest -> dedup -> tiered-gate -> Bash/jq batch-regen procedure as Default Mode514Stage 7 above (Steps 2-11 apply verbatim: mixed-sender handling, per-key dedup/tally,515evidentiary threshold, archive-scope isolation, the consolidated gate, the Bash/jq write path,516batch index regen, the feedback-loop cap, revocation/edit UX, and success-signal logging). The517ONLY difference is key derivation (Step 1):5185191. **Key derivation (`--all` mode)**: key off the Stage 2.5 bucket grouping (domain, or full520 address for freemail/shared domains) as the harvest trigger/start key, cross-referenced521 against the per-split Stage 6 executed totals — only bucket members whose Message-ID is522 confirmed `executed` in some split's state-file diff count as evidence; approved-but-expired523 or failed IDs within an otherwise-executed bucket contribute nothing. Derive each executed524 member's identity the same way as default mode525 (`.claude/scripts/email-preference-harvest.sh identity "<sender>"`) and group by the526 resulting key — the Stage 2.5 bucket is the harvest *trigger*, not itself the memory key; one527 review bucket may fan out into multiple memory-write candidates (design §1.5).528529Run this Stage 7 pass once, after Stage 6 totals are final for the whole `--all` sweep (all530splits), not per-split.531532---533534## Archive Scope (`scope=archive`): Account's Archive Folder with Extra-Caution Gates535536`--archive` is an orthogonal scope flag, composable with either mode: it sets `BASE_QUERY` to the537resolved account's archive-folder token (Stage 0) — `folder:Gmail/.All_Mail` for `account=gmail`538(the exact notmuch token, wrapper-contracts.md §11), `folder:Logos/.Archive` for `account=logos`539(the real Proton Archive folder; Logos has no `.All_Mail`/`.Spam` sibling) — as the classify540QUERY base. No wrapper flag exists or is used for folder scoping. Composition:541542- `/email --archive` = default 50-step pass stepping through the account's archive folder543 (All Mail for gmail, Archive for logos).544- `/email --all --archive` = full archive-folder sweep + bucket approval + drain.545546**Archive of record, per account**:547548| Account | Archive folder | Approximate size | Blast-radius note |549|---------|----------------|-------------------|---------------------|550| `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 |551| `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 |552553Proportionate extra gates apply identically to BOTH accounts (full rationale:554`context/project/email/domain/archive-mode-risk.md`):5555561. **Pilot gate first, per account**: before ANY full-scale archive-scope operation FOR A GIVEN557 ACCOUNT, that account's bounded pilot pass must have been run and acknowledged — see "Pilot558 Gate for `--archive`" below. Check this gate at Stage 0, before any sweep or classify call,559 keyed to the resolved `account`.5602. **Second, distinctly-worded blast-radius confirmation**: after the normal review gate561 (Stage 3 review in default mode / Stage 2.5 bucket approval in `--all` mode) and BEFORE562 Stage 4/execute, ask a separate AskUserQuestion that names the blast radius explicitly:563 "Yes, operate on N archived messages in All Mail" (gmail) / "Yes, operate on N archived564 messages in Logos Archive" (logos) / "No, stop here". Generic "proceed?" wording is not565 acceptable; the option label must state N and the account's archive-folder name.5663. **Asymmetric, corroborated confidence bar for deletes**: archive-scope DELETE bulk-approval567 requires more than the inbox 0.90 gate — the bucket must be BOTH `min()`-confidence568 `>= 0.90` AND corroborated by a deterministic rule-tier reason (e.g.569 `custom-domain-delete:*` at 0.98); keyword-fallback-only reasons never license an570 archive-scope bulk delete option, regardless of numeric confidence. Archive proposals571 (recoverable moves) keep the standard bar. When in doubt, offer archive-in-place/skip, not572 delete. This bar is identical for both accounts.5734. **Reversible-then-hard two-phase**: archive-scope deletes ALWAYS stop at the recoverable574 hop (move to Trash). `--expunge-trash` is opt-in ONLY — a separate, explicit user request575 in a later invocation, with its own `--execute --confirm-manifest` gate (the wrapper576 already tracks the two hops in separate state files). Never expunge by default; never577 bundle move-to-Trash and expunge in one pass.5785. **Per-chunk classify** (inherited from `--all` Stage 2): never attempt a single unchunked579 classify over the archive folder, for either account.5806. **Never auto-chain `/email --sync`** after an archive drain. The wrapper's internal581 per-run `mbsync <account-channel>` reconcile is frozen contract behavior; the separate582 `--sync` skill run is a human decision for a later, calmer moment.583584## Pilot Gate for `--archive`585586Because this extension has never been validated against a live mailbox at archive scale, the587FIRST archive-scope operation **for a given account** must be a bounded pilot, and full-scale588`--archive` for that account is REFUSED until that account's pilot has been run and explicitly589acknowledged. **The gate is scoped per account**: a Gmail pilot-ack does NOT license a Logos590archive-scope run, and vice versa — each account's first full-scale archive pass must clear its591own pilot independently.592593- **Pilot bound**: scope the pilot to a slice of the account's archive folder capped at low594 thousands — for `account=gmail`, e.g. one historical year595 (`folder:Gmail/.All_Mail and date:2019-01-01..2019-12-31`, picking a year whose census count596 is ≲ 3,000); for `account=logos`, the entire `folder:Logos/.Archive` folder is already only597 ~54 messages (live probe), so the "pilot" and the full archive coincide — no further date598 slicing is needed before acknowledging it. In default mode, simply the ordinary 50-step pass599 also qualifies as a pilot for either account. The pilot runs the FULL flow (sweep → buckets →600 approval → split → drain → verify) at reduced blast radius.601- **Acknowledgement record, keyed by account**: after a pilot completes and Stage 6602 verification is clean, ask the user to acknowledge the pilot outcome (AskUserQuestion, root603 session). On acknowledgement write (or update) `$MANIFEST_DIR/archive-pilot-ack.json` — always604 this single, hardcoded file path; per-account filenames (e.g. `archive-pilot-ack-gmail.json` /605 `archive-pilot-ack-logos.json`) are never used. The file holds a JSON array of per-account606 records, each shaped exactly as: `{"account": "gmail|logos", "acknowledged_at": "<ISO8601>",607 "pilot_scope": "<query>", "messages_processed": N, "chunk_size_verdict": "keep 1000 | adjust to608 <n>"}`. If the file does not yet 609610…(truncated)