aboutsummaryrefslogtreecommitdiff
path: root/docs/specs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-15 10:24:40 -0500
committerCraig Jennings <c@cjennings.net>2026-06-15 10:24:40 -0500
commit45e0f6e896b2c34de25d5c3aa18474c79d6a1e72 (patch)
tree3f4d822aa5da53f4e6bbebbdc7fb400a2b212189 /docs/specs
parenta5c9f48220cd52770f10f7627922b9fc8e2204cc (diff)
downloaddotemacs-45e0f6e896b2c34de25d5c3aa18474c79d6a1e72.tar.gz
dotemacs-45e0f6e896b2c34de25d5c3aa18474c79d6a1e72.zip
docs: move specs to docs/specs/ with lifecycle-status filenames
Separate the 27 formal specs from working notes. Specs move to docs/specs/, notes stay in docs/design/. Each spec carries its lifecycle in the filename (-spec, -spec-doing, -spec-implemented, -spec-superseded) plus an authoritative ID and STATUS property drawer. The status came from checking each spec against the code, not the doc's own field: 6 implemented, 8 in progress, 12 not started, 1 superseded. Inbound links become org-id links so future status renames don't break them; code-comment paths repoint to docs/specs/. Working notes, inventories, reviews, and brainstorms stay in docs/design/.
Diffstat (limited to 'docs/specs')
-rw-r--r--docs/specs/ai-kb-spec.org348
-rw-r--r--docs/specs/ai-vterm-spec-superseded.org163
-rw-r--r--docs/specs/cache-helper-design-spec-implemented.org169
-rw-r--r--docs/specs/company-to-corfu-migration-spec.org328
-rw-r--r--docs/specs/coverage-spec-implemented.org210
-rw-r--r--docs/specs/debug-profiling-spec.org207
-rw-r--r--docs/specs/dev-setup-project-spec.org162
-rw-r--r--docs/specs/dupre-clear-theme-spec.org93
-rw-r--r--docs/specs/face-font-diagnostic-popup-spec.org197
-rw-r--r--docs/specs/flycheck-modeline-customization-spec-implemented.org319
-rw-r--r--docs/specs/gloss-spec-doing.org320
-rw-r--r--docs/specs/gptel-gh-tool-spec.org1065
-rw-r--r--docs/specs/gptel-git-tools-magit-backend-spec.org196
-rw-r--r--docs/specs/gptel-network-tools-spec.org411
-rw-r--r--docs/specs/init-load-graph-spec-doing.org833
-rw-r--r--docs/specs/keybinding-console-safety-spec-doing.org943
-rw-r--r--docs/specs/mcp-el-gptel-integration-spec-doing.org1438
-rw-r--r--docs/specs/messenger-unification-spec.org212
-rw-r--r--docs/specs/music-config-without-emms-spec.org547
-rw-r--r--docs/specs/org-faces-spec-implemented.org154
-rw-r--r--docs/specs/signal-client-spec-doing.org254
-rw-r--r--docs/specs/theme-studio-package-faces-spec-doing.org590
-rw-r--r--docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org580
-rw-r--r--docs/specs/theme-studio-seeding-engine-spec-doing.org354
-rw-r--r--docs/specs/theme-studio-structured-output-spec.org157
-rw-r--r--docs/specs/utility-consolidation-spec-doing.org1220
-rw-r--r--docs/specs/vterm-to-ghostel-migration-spec-implemented.org424
27 files changed, 11894 insertions, 0 deletions
diff --git a/docs/specs/ai-kb-spec.org b/docs/specs/ai-kb-spec.org
new file mode 100644
index 000000000..fbd35ca55
--- /dev/null
+++ b/docs/specs/ai-kb-spec.org
@@ -0,0 +1,348 @@
+:PROPERTIES:
+:ID: 03742426-35ce-41c5-aed7-d4e248e91833
+:STATUS: not-started
+:END:
+#+TITLE: Design: AI Knowledge Base (ai-kb)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Ready. Six reviews incorporated (=ai-kb-review.org= through =-review6.org=; all 2026-05-24). Review 6's UX/performance pass is folded in: one safety model for human Emacs edits (after-save runs index+lint+commit), a required =:SUMMARY:=, the generated index made invisible to backlink/orphan logic, first-class browsing commands (dashboard/find/search/show/backlinks/map), a full org-roam *profile* on switch (not just dir/db), conditional sync, lexical query ranking, a =raw/= size/type policy, and performance budgets. The four original blockers (version control + recovery, switch-state safety, startup surface, project-awareness) and review 4's two write-loop caveats (push-failure contract, index regeneration) have decisions. Review 3's operational shape (a repo-resident agent-neutral contract, a minimal CLI, maintenance commands, multi-agent provenance) is adopted. Review 5's implementation-hardening is folded in: the commit gate runs the *full* =ai-kb lint= (not just node org-lint), an explicit org-lint fatal-check list, observable push failures, a testable =ai-kb query= contract, a Step 1a/1b split, and ID-first durable pointers. Cross-agent is *not a near-term goal* (Craig, 2026-05-24): v1 ships the Claude adapter over the neutral contract, and other-agent adapters (Codex/Ollama, MCP) are deferred to [[*vNext][vNext]]. The architecture is decided and Step 1 is buildable; the build-time implementation choices (limits, perf budgets, scoring weights, map, after-save UX + recursion guard) are settled with calibratable defaults in [[*Open decisions][Open decisions]].
+
+In scope: Step 1 (store + contract/CLI + global rule + provisioning) and Step 2 (Emacs browsing layer). Step 3 (migrating =.ai/sessions= and workflows in) and the full LLM-Wiki layer are *deferred to their own specs* — see [[*vNext][vNext]].
+
+* Scope decision: memory store, not (yet) an LLM Wiki
+
+ai-kb v1 is a *global, durable, cross-project memory store* for AI coding agents (Claude Code today; agent-neutral by contract): org-roam nodes holding lessons, principles, Craig's preferences, reusable procedures, and durable observations. It is the concrete first slice of the broader "org-roam as agent memory" vision in [[file:agentic-knowledgebase.org][agentic-knowledgebase.org]].
+
+It is *not* a Karpathy-style LLM Wiki in v1. That pattern — immutable =raw/= sources, compiled =wiki/= synthesis pages, =schema.org=, source hashes, and full ingest/query/lint pipelines — is a larger product whose value is *grounding compiled knowledge in re-checkable sources*. v1 adopts the one piece that pays off immediately: a =raw/= capture for *external* sources (see [[*Grounding external sources][Grounding external sources]]). The rest of that machinery is the documented evolution path (see [[*vNext][vNext]]); v1's structure is chosen so it can grow that way without a rewrite.
+
+* Problem
+
+AI coding agents start every session cold. Continuity today is per-project and flat. There is no home for durable, *general* knowledge that should follow the agent into every repo — engineering lessons, Craig's cross-project preferences, reusable procedures (e.g. "move a local repo to git.cjennings.net with a mirror-to-GitHub hook") — and no link structure relating one piece of knowledge to another. A flat shared lessons file would solve "knowledge that follows the agent" alone; org-roam is chosen over it for *link structure*, *first-class browsing* (node-find, backlink buffer, graph), and a substrate that grows toward the agentic-KB vision. The complexity is earned by those three.
+
+* Memory tiers
+
+Naming the tiers (after Nexus's vocabulary) so every agent routes consistently:
+
+- *T1 — session scratch:* the current chat, spawned-agent handoffs. Ephemeral.
+- *T2 — project memory:* per-project =~/.claude/projects/<encoded-cwd>/memory/=, =.ai/notes.org=, active project decisions. Minor or project-specific.
+- *T3 — ai-kb:* global, durable, cross-project. Significant and general: lessons, principles, preferences, reusable procedures, durable observations.
+
+T2's =MEMORY.md= shrinks toward an index: for significant items it points at the T3 (ai-kb) node rather than holding the content. ai-kb is *not* a dump for T1/T2 breadcrumbs — the proactive-write bar (below) keeps it T3-only.
+
+* Concept: two layers
+
+- *Store* — a git repository of org files (each a valid org-roam node). The agent reads/writes these directly and never touches the SQLite database; the files are the source of truth.
+- *Emacs/org-roam integration* — so Craig can browse with backlinks and the graph. org-roam keys off one global =org-roam-directory= + =org-roam-db-location= per session, so ai-kb cannot be live alongside the personal roam; the integration is a *switch* that installs a full org-roam profile (see [[*The Emacs switch: a full org-roam profile][the switch profile]]).
+
+* Storage, version control, and recovery
+
+ai-kb is its *own git repository* — not in =~/sync/org= (Syncthing has proven unreliable for backup/restore: no history, silent =.sync-conflict= files) and *not* in =~/.emacs.d= (publicly mirrored to GitHub; ai-kb holds personal/work-private knowledge that would leak).
+
+- *Location:* =~/.local/share/ai-kb= (XDG =$XDG_DATA_HOME/ai-kb=).
+- *Origin:* a bare repo on =git.cjennings.net= (=git@cjennings.net:ai-kb.git=), *private — no public GitHub mirror*. This is the recovery layer: full history, clone-to-restore.
+- *No Syncthing.* git is the sole sync and backup; multi-machine concurrency surfaces as ordinary git merges, not silent conflict files.
+- *org-roam scope:* =org-roam-directory= points at the repo root; =raw/= *and the generated index files* (=index*.org=) are *excluded* from the scan (=org-roam-file-exclude-regexp=) so neither raw captures nor the index become noisy roam nodes. The LLM-Wiki vNext would add a compiled =wiki/= layer; v1 keeps compiled nodes flat at root.
+- *Generated files are invisible to semantics.* =index*.org= and =raw/= are excluded from the org-roam scan, the graph/map, and curation's backlink/orphan calculations. The index references nodes as *plain text* (=Title (UUID)=), never =[[id:...]]= links — otherwise every node would gain an artificial backlink from the index and orphan detection would be meaningless. The index is a navigation artifact, not a semantic backlink source.
+
+* Write protocol and synchronization
+
+The agent writes nodes from the shell, possibly from several machines or concurrent processes, so the write path is a defined protocol, not a bare =git push=. Encapsulated in the =ai-kb remember= operation (see [[*The agent contract and operations][operations]]):
+
+1. *Before write:* =git fetch=; if behind and clean, =git pull --ff-only=; if diverged or the tree is dirty with unrelated changes, *abort and surface* — don't auto-merge.
+2. *Write/edit the node.*
+3. *Regenerate the index* from node properties (see [[*Startup surface and retrieval contract][Startup surface]]).
+4. *Validate the whole change, not just the node — this is the safety boundary.* Run the full =ai-kb lint= over the change set before committing: =org-lint= fatal checks on *both* the edited node and the regenerated index, index freshness/completeness, duplicate =:ID:=, broken =[[id:...]]= links (excl =raw/=), missing required properties, invalid project slugs, and a credential/secret scan of nodes *and* =raw/=. Any failure aborts the commit. (Gating only on single-node org-lint would let a stale index or a leaked secret through — then the write protocol isn't the boundary the spec claims.)
+5. *Commit locally — always.* The local commit is the durable record.
+6. *Push — best-effort, non-blocking, never fatal, and observable.* =remember= commits locally and does *not* push; a background =systemd --user= timer (~15 min) pushes when the repo is ahead. A failed push (offline, network blip, gpg-agent SSH key not loaded — observed this session) is *logged to a state file* (under the repo or =$XDG_STATE_HOME/ai-kb=) and ignored, never erroring or hanging the agent. Visibility comes from three surfaces so the KB can't go quietly local-only: the log, =ai-kb doctor= (reports "ahead N", "push failed", or "remote diverged"), and a one-line startup/adapter nudge when there are unpushed commits or a recorded rejection.
+7. *On push rejection* (remote moved): do *not* blind-retry. Fetch, record the divergence, leave the local commit intact for resolution.
+8. *Same-machine concurrency:* =flock= around =remember= serializes concurrent agents (Claude + Codex + an Emacs save) so they don't race. A v1 file lock; not a daemon.
+
+* Why a separate database
+
+org-roam supports one active =org-roam-directory= / =org-roam-db-location= at a time. ai-kb gets its own directory (the repo above) and its own database (=~/.emacs.d/org-roam-ai.db= — a regenerable cache). The personal roam (=~/sync/org/roam/= + =~/.emacs.d/org-roam.db=, recipes etc.) is never scanned or modified.
+
+* The sync model
+
+The =.org= files are truth; the SQLite db is a cache indexing nodes and =[[id:...]]= links that powers Emacs's backlink buffer, node-find, and graph. Editing in Emacs updates the cache on save (=org-roam-db-autosync-mode=); agent shell writes don't, so =org-roam-db-sync= re-scans. The key consequence: *the agent never needs the db to check links* — they live in the files and are grepped (always current). *Craig's Emacs browsing* needs the cache current, so the switch-to-ai-kb command syncs on entry; the agent may also fire =emacsclient -e '(cj/ai-kb-db-sync)'= for immediacy, but agent correctness never depends on Emacs running.
+
+* Proactive-write rule
+
+The agent writes a node *unprompted* when something is =durable= (true beyond this session) *and* =general= (T3, not tied to the current repo; project-specific knowledge goes to T2). The bar, to keep out noise: genuinely worth recalling or linking later — a principle, a reusable procedure, a preference, a non-obvious lesson — not routine status or anything re-derivable from code or git. New nodes link to related existing ones (grep candidates by title/tag first) and trigger an index regeneration.
+
+*Contradiction guard:* if a write would contradict an existing node that affects agent behavior or a stated preference, the agent does *not* silently overwrite. It marks both =:STATUS: contested=, records the conflict, and asks Craig before changing the canonical node.
+
+* Node format and conventions
+
+#+begin_src org
+:PROPERTIES:
+:ID: <uuid, generated with `uuidgen`>
+:PROJECTS: :general: ; or :deepsat: :emacs: ... (see slug rule below)
+:CREATED: 2026-05-24
+:UPDATED: 2026-05-24
+:CREATED_BY: claude-code ; claude-code | codex | ollama | human
+:CONFIDENCE: user-stated ; user-stated | observed | inferred | external
+:VISIBILITY: personal ; personal | work-private
+:SOURCE: chat 2026-05-24 ; free-form, or a raw/ path for external sources
+:STATUS: current ; current | contested | superseded
+:SUMMARY: One sentence, written for retrieval and index display.
+:END:
+#+title: Concise node title
+#+filetags: :principle:emacs:
+
+Body. Link related nodes with [[id:OTHER-UUID][Their title]], optionally prefixed
+with a relation label: SUPERSEDES, CONTRADICTS, RELATES_TO, IMPLEMENTS, DERIVED_FROM.
+#+end_src
+
+- *Filename:* org-roam convention — =YYYYMMDDHHMMSS-slug.org= (or =slug.org= for stable, frequently-linked nodes). Filenames are for humans and can change during curation, so they are *not* the durable identity.
+- *ID:* a real UUID (=uuidgen=) — org-roam won't index a node without one, and the =:ID:= is the *durable identity*. External pointers (a per-project =MEMORY.md= → an ai-kb node) are *ID-first*: =ai-kb: <Title> (<UUID>)=, resolved by ID with title as fallback, so a rename or curation merge doesn't dangle the pointer.
+- *Type tags* (=#+filetags:=): =:principle:= =:preference:= =:procedure:= =:observation:= =:reference:=.
+- *Project slugs* (=:PROJECTS:=): derived from the project directory basename (so =~/.emacs.d= → =:emacs:=, the DeepSat repo → =:deepsat:=), with =:general:= for cross-cutting nodes. The derivation rule lives in the contract so every agent produces the same slug; new slugs are recorded in the index's project list.
+- *Provenance:* =:CREATED_BY:= and =:CONFIDENCE:= let later curation and trust policy distinguish "Craig stated this" from "a model inferred it." =:CONFIDENCE:= here is *provenance* (how the claim was obtained), not a numeric grounding score — the latter is vNext. =:VISIBILITY:= is two-valued in v1 (the full =public|work-private|secret= taxonomy is vNext); secrets are never stored at all (see [[*Security and privacy][Security]]).
+- *Summary:* a *required* one-line =:SUMMARY:= property, written for retrieval. =ai-kb index= and =ai-kb query= read it straight from the property, so the index rebuilds fast and locally — no inferring from the first paragraph (inconsistent) and no LLM call (slow, nonlocal).
+- *Relation labels:* a small fixed vocabulary used in link context now; full typed-link catalog storage is vNext.
+
+* Grounding external sources
+
+The one LLM-Wiki piece adopted in v1: keep compiled knowledge re-checkable where an external source exists.
+
+- *Node authored from an external source* (web article, fetched doc, transcript, API result): capture under =raw/= and point =:SOURCE:= at that path. *By default store the URL, retrieval date, and the relevant excerpt* — store full external text only when it is user-owned, licensed for the use, or operationally necessary (this is a private KB, but copyright still applies). A later agent can re-ground a suspicious node against the source instead of trusting its own prior summary.
+- *Node authored from the conversation or direct observation*: only the free-form =:SOURCE:= pointer; no raw capture (the source is not an external artifact).
+- =raw/= is append-only in spirit and excluded from org-roam's scan.
+- *Size/type policy:* a small org stub (URL + retrieval date + bounded excerpt) is the default capture, under a maximum excerpt size; a larger full source file goes under =raw/files/= only when explicitly requested. The credential scan runs over *text* only — binary files are skipped (by type or byte-sniff). =ai-kb doctor= reports the raw-directory size and =curate --dry-run= flags unusually large raw files and raw captures with no compiled node, so bloat stays visible.
+
+* Startup surface and retrieval contract
+
+Passive grep-on-demand gets under-used; loading the whole KB wastes context. Two tiers:
+
+- *L1 — always loaded:* the global rule adapter (=claude-rules/ai-kb.md= for Claude), tiny. It carries the path, routing rule, link-grep recipes, and: *when a task may involve durable knowledge, read the index first.* Include concrete example triggers so it actually fires — e.g. "before choosing a formatter/test/lint convention," "before a multi-step procedure you've likely done before (repo setup, release, deploy)," "when Craig states a preference," "when you hit a non-obvious gotcha worth keeping."
+- *L2 — on demand:* =index.org= at the repo root, read at session start only when L1's condition applies.
+- *Full nodes* read only when the index points at them or Craig asks.
+
+=index.org= is *generated output*, never hand-maintained — that is what keeps it from drifting from the nodes. A regeneration script greps node properties (=#+title:=, =:ID:=, type tag, =:PROJECTS:=, =:UPDATED:=, =:STATUS:=) and rebuilds the file with a "generated, do not edit" marker. It runs in provisioning, in the curation pass, on demand, and as step 3 of every =remember=. =lint --index= checks: every listed id resolves, every =current= node is listed, contested/superseded sections are accurate, the size budget holds (split into =index-procedures.org= etc. when exceeded).
+
+#+begin_example
+,* Procedures
+| Title | ID | Summary | Projects | Updated |
+,* Preferences
+| Title | ID | Summary | Projects | Status |
+,* Contested / needs review
+| Title | Issue | Last touched |
+#+end_example
+
+* Checking links (agent recipes)
+
+No database needed; grep the files (excluding =raw/=):
+
+- *Backlinks to a node* — =rg -l "id:<UUID>" ~/.local/share/ai-kb --glob '*.org' --glob '!raw/**'=.
+- *Forward links from a node* — grep that node's file for =id:= links.
+- *Find a node to link to* — grep titles/tags.
+
+* Node validity (org-lint)
+
+The agent writes raw org from the shell, bypassing Emacs's structural editing, so malformed org (broken drawer, bad property, broken timestamp) can slip in and make =org-roam-db-sync= choke or mis-index. Distinct from the semantic link/credential checks; both run.
+
+- *On write:* =org-lint= via =emacs --batch=, gating on a concrete *fatal-check* list (not the vague "error-level," so a future org-lint behavior change can't silently weaken or over-tighten the gate, and tests target the list directly):
+ - malformed property drawer
+ - missing or duplicate =:ID:=
+ - an invalid required-property line
+ - missing =#+title:=
+ - structurally invalid org that prevents parse/index
+ Style warnings stay non-fatal. A node hitting a fatal check is not committed.
+- *In curation:* an =org-lint= sweep over all nodes catches drift or bad Emacs-side hand-edits.
+
+Cheap (sub-second batch on one small file); the safety net that makes "the agent writes raw org" trustworthy. Reuses/extends the project's =scripts/lint-org.el=. This node-level gate is run as part of the full =ai-kb lint= the write protocol invokes before commit (step 4 above) — it never stands alone as the only check.
+
+* The agent contract and operations
+
+The access layer is an *agent-neutral contract*, not a Claude-only prompt snippet. Cross-agent use is not a near-term goal (deferred to vNext), but making the contract repo-resident and neutral *in shape* costs nothing now, future-proofs that path, and — more importantly for v1 — the CLI earns its place on Claude-only grounds: it is the clean, atomic, testable home for the write protocol, index regeneration, and lint, far better than scattering them across prose rules.
+
+- *Canonical contract:* lives *in the repo* (=~/.local/share/ai-kb/AGENT_CONTRACT.org=) — the source of truth for the node format, routing rule, write protocol, and operations. It travels with the store.
+- *Adapters* point at it: =claude-rules/ai-kb.md= (symlinked into =~/.claude/rules/= by rulesets =make install=) is the Claude adapter. Other agents get their own thin adapter when wanted (deferred — see [[*Open decisions][Open decisions]]).
+- *Operations* — a small =ai-kb= CLI (shell, calling Emacs for org-lint/index work) is the canonical surface, so humans and every agent share one contract. For performance: prefer =emacsclient= when a daemon is up (=emacs --batch= fallback), and run lint + index in a *single* Emacs invocation per =remember= rather than one startup per check. The full-lint gate stays on for v1; if timing crosses the perf budget at scale, split lint into cheap always-on checks (the edited node + index) and a slower full-sweep, but don't pre-optimize.
+ - =ai-kb doctor= — repo present, remote reachable + private, branch state, org-roam db buildable, required tools installed (incl. =graphviz= if the map needs it), adapter linked, no obvious secrets, raw-directory size.
+ - =ai-kb status= — fast, non-diagnostic state for the dashboard/nudge: branch ahead, last push failure, node count, last index time, curation-due. (May be a =doctor --status= mode.)
+ - =ai-kb show <id-or-title>= — resolve an ID-first pointer and print the node (path + body); the testable primitive the Emacs =show-node= wraps.
+ - =ai-kb backlinks <id>= — list nodes linking to =<id>=, excluding =raw/= and the generated index.
+ - =ai-kb index= — regenerate =index.org= from node properties.
+ - =ai-kb query <context>= — read the index, return relevant nodes. It is the surface adapters call before spending context on full nodes, so it has a *testable contract* even though v1 retrieval is plain lexical: default output is plain text (one node per line), with =--json= for tests and tools; fields are title, ID, summary, projects, status, updated, path, and the *match reason* (matched-title / tag / summary / body); it searches index rows + title/tags/properties/body; ranking is a simple lexical score (title > tag/project/status > summary > body) with most-recently-updated as the *tie-breaker* — recency alone would bury old stable preferences and procedures, which are exactly what the store exists to preserve; a default max-result count; =raw/= paths appear only as source references, never as primary results; exit codes distinguish no-match, invalid/missing KB, and a lint/index failure.
+ - =ai-kb remember= — the write protocol above (fetch/ff, write, regenerate index, full lint gate, commit; push is the timer's job; under =flock=).
+ - =ai-kb lint= — org-lint fatal checks, duplicate ids, broken id-links (excl =raw/=), missing required properties, bad project slugs, stale/incomplete index, credential scan of nodes and =raw/=. This is what =remember= runs before commit and what curation runs as a sweep.
+ - =ai-kb curate --dry-run= — report duplicates, orphans, contested/superseded nodes, raw captures with no compiled node, nodes untouched past a horizon.
+ - =ai-kb sync= — =org-roam-db-sync= against ai-kb (Emacs-side helper).
+- *Admin split:* destructive operations — merge nodes, delete a node or raw capture, rewrite backlinks, mark superseded — are *human-confirmed only*, never automatic.
+- *Capability levels* (named so adapters know their lane): =file-only= (read/grep/template-write), =cli= (call =ai-kb=), =mcp= and =semantic= are vNext. Claude v1 uses =cli= with the rule adapter; until the CLI exists, =file-only= following the contract template is the bootstrap path.
+
+* The Emacs switch: a full org-roam profile
+
+Switching is *not* a two-variable rebind. The personal org-roam surface has many globals and hooks that would misroute into ai-kb: =org-roam-directory=, =org-roam-db-location=, =org-roam-dailies-directory= (personal journals), capture templates, tag/topic/recipe find wrappers that reference personal template paths, the agenda/refile finalize hook (=cj/org-roam-add-node-to-agenda-files-finalize-hook=) that can add captured nodes to personal agenda files, and the completed-task→daily hook (=cj/org-roam-copy-todo-to-today=). The switch therefore installs an *ai-kb profile*, restored exactly on exit:
+
+- directory + db location + the file-exclude regexp (=raw/= + =index*.org=)
+- dailies disabled (or pointed nowhere personal)
+- ai-kb-only capture templates
+- topic/project/recipe find wrappers disabled or rebound to the ai-kb profile
+- agenda/refile finalize hook + completed-task→daily hook neutralized so nothing from ai-kb lands in personal agenda files or journals
+- *Abnormal exit:* if Emacs is killed while switched, the config re-asserts the personal profile at startup, so a crash can't leave personal hooks rescoped into ai-kb.
+
+Tests assert *profile-level* behavior — not just dir/db restore, but that the completed-task and agenda hooks don't fire into ai-kb while switched, and that personal templates/dailies are untouched.
+
+* Human edits must use the same safety model
+
+One safety boundary for *both* agent and human writes. =ai-kb remember= linting + indexing + committing must not be bypassed when Craig edits a node in Emacs and saves. The v1 mechanism: an =ai-kb= minor mode on buffers under the store with an =after-save-hook= that runs the same post-save sequence under =flock= — regenerate index, full =ai-kb lint=, commit, update push state. A lint failure on save surfaces the problem rather than silently committing a broken node. (Read-only-with-an-edit-command is the fallback if the after-save approach proves fiddly; either way there is exactly one write path.)
+
+* Emacs browsing surface
+
+The spec promises first-class browsing, so Step 2 names the commands rather than leaving Craig to remember low-level org-roam + git details. All operate within the ai-kb profile and exclude =raw/= + generated index:
+
+- =cj/ai-kb-dashboard= — a status buffer (or =index.org= with a banner): active KB, node count, unpushed commits, push-failure state, curation-due, last index time, last sync time. Wraps =ai-kb status=/=doctor=.
+- =cj/ai-kb-find-node= — =org-roam-node-find= in the profile.
+- =cj/ai-kb-search= — =ai-kb query= or =consult-ripgrep= scoped to the store.
+- =cj/ai-kb-show-node= — resolve an ID-first pointer (=ai-kb: Title (UUID)=) and open the node.
+- =cj/ai-kb-backlinks= — backlinks excluding =raw/= and the generated index.
+- =cj/ai-kb-map= — a graph/map via built-in =org-roam-graph= or a small DOT export from =[[id:...]]= links, excluding =raw/= + index, filterable by project/tag/status. =graphviz= is checked by =ai-kb doctor= if this command needs it. Richer interactive graph (=org-roam-ui=) is vNext.
+
+* Sync only when stale
+
+=org-roam-db-sync= on every switch becomes a visible pause as the store grows, and agent correctness never depends on the db. So =ai-kb sync= (and the switch's entry sync) runs *only when needed* — db missing, or db older than the newest node/index — or when forced with a prefix arg, showing a "syncing…/done" status. Consider running it asynchronously from the dashboard/switch with a pending/running/done indicator.
+
+* Maintenance and curation
+
+The proactive-write bar controls intake; nothing controls rot, and the system creates memories unprompted, so a minimal maintenance loop is v1 (read-only commands; destructive execution human-gated):
+
+- =ai-kb doctor= / =ai-kb lint= — health and validity (above).
+- =ai-kb curate --dry-run= surfaces four buckets — duplicates to merge, stale/superseded nodes, orphans (no back- or forward-links), over-broad nodes to split. Craig decides; the agent executes *human-confirmed* merges/splits, repointing =[[id:]]= backlinks (grep + rewrite) and re-linting. A =:LAST_CURATED:= stamp rotates the pass through least-recently-touched nodes.
+- *Trigger (node-count):* curation is "due" when roughly N nodes have been added or gone untouched since the last =:LAST_CURATED:=; =ai-kb doctor= and the session-startup surface emit a one-line nudge when due (mirroring the existing task-review habit). The interactive pass itself is a global workflow at =~/code/rulesets/.ai/workflows/ai-kb-curate.org=, available from every project's session.
+- *Pointer integrity:* external pointers are ID-first, so a rename is safe; but a merge or supersede *changes the canonical ID*, so before merging/deleting, grep for inbound references (other nodes' =[[id:]]= and per-project =MEMORY.md= =ai-kb: ... (UUID)= pointers) and repoint the old ID to the new.
+
+* Security and privacy
+
+ai-kb lives in a *private* repo (cjennings.net only, no public mirror), removing the main leak surface. v1 rule: *private but not a secret store* — no credentials/tokens/keys in nodes or =raw/=; =ai-kb lint= scans both for common credential patterns before commit and *fails* on a hit (secrets move to a secure reference, not ai-kb). =:VISIBILITY:= is two-valued (=personal= / =work-private=) in v1; the full =public|work-private|secret= taxonomy and a public/private split are vNext, for when sharing or publishing a subset is real.
+
+* Provisioning
+
+The pieces span the rulesets repo, this repo, and the ai-kb repo. =make ai-kb-init= (wrapping =scripts/setup-ai-kb.sh=) is idempotent. *One-time server bootstrap* (distinct from the per-machine clone, and not doable by the local script): =sudo git init --bare /var/git/ai-kb.git && chown= on cjennings.net, plus the github-mirror hook left *off* for this repo.
+
+Per-machine, ordered:
+1. Clone =git@cjennings.net:ai-kb.git= to =~/.local/share/ai-kb= (or =git init= + add remote on the very first machine).
+2. =make ai-kb-init=: seed =index.org= + a README/index node with a generated =:ID:=; install the =ai-kb= CLI onto =PATH=; =ai-kb index=; install and =systemctl --user enable --now= the =ai-kb-push.timer= + =.service= units (the debounced background push); best-effort =ai-kb sync= if an Emacs server is up.
+3. =cd ~/code/rulesets && make install= — symlinks the =claude-rules/ai-kb.md= adapter into =~/.claude/rules/= (and syncs the =ai-kb-curate.org= workflow into projects via the usual startup rsync).
+4. =ai-kb doctor= to confirm the machine is wired correctly (repo, remote, CLI on PATH, push timer active, adapter linked, db buildable, no secrets).
+
+* Build plan
+
+Step 1 splits into two slices by dependency — =remember= needs =index= + =lint=, and the adapter needs =remember=, so the safe write path (1a) lands first and the read/maintenance/timer pieces (1b) follow. Same scope, cleaner sequencing.
+
+*** Step 1a — the safe write path (minimum usable)
+
+- The =ai-kb= git repo (bare on cjennings.net + clone at the XDG path), seed =index.org=, =AGENT_CONTRACT.org=.
+- =ai-kb index= (regenerate from properties incl. =:SUMMARY:=), =ai-kb lint= (full check set: org-lint fatal gate + required-property check incl. =:SUMMARY:= + credential scan), =ai-kb remember= (write protocol: fetch/ff, write, regen index, full-lint gate, commit, =flock=; lint+index in one Emacs invocation), =ai-kb doctor= / =ai-kb status= (health + push-state + raw-size report).
+- =claude-rules/ai-kb.md= adapter (points at the contract; routing + proactive + contradiction rules + concrete L1 triggers + "use =ai-kb remember=, never bypass =ai-kb lint="); =make install= links it.
+- =scripts/setup-ai-kb.sh= + =make ai-kb-init=; the one-time server bootstrap documented.
+
+After 1a the agent can remember, lint, and check health — the safe write path exists.
+
+*** Step 1b — retrieval, maintenance, push
+
+- =ai-kb query= (the testable retrieval contract: lexical score + recency tie-break + match reason) plus the =ai-kb show= / =ai-kb backlinks= inspection helpers.
+- =ai-kb curate --dry-run= (incl. large/orphan =raw/= reporting) and =ai-kb sync= (only-when-stale).
+- =ai-kb-push.timer= + =ai-kb-push.service= =systemd --user= units (debounced background push) installed and enabled by =setup-ai-kb.sh=, plus the push-failure log + =doctor=/startup surfacing.
+- =~/code/rulesets/.ai/workflows/ai-kb-curate.org= — the human-gated curation workflow, surfaced when the node-count trigger makes it due.
+
+** Step 2 — Emacs browsing layer
+
+In =org-roam-config.el=:
+- The *ai-kb org-roam profile* (=cj/org-roam-switch-to-ai-kb= / =…-to-personal=): dir + db + exclude regexp (=raw/= + =index*.org=), dailies/templates/find-wrappers/agenda+completed-task hooks all rescoped or neutralized, restored exactly on exit, re-asserted at startup after an abnormal exit.
+- *Edit safety:* an =ai-kb= minor mode whose =after-save-hook= runs index + full lint + commit + push-state under =flock=, so human edits use the one safety model.
+- *Conditional sync* =cj/ai-kb-db-sync=: only when the db is missing/stale or forced, with a status indicator.
+- *Browsing surface:* =cj/ai-kb-dashboard=, =-find-node=, =-search=, =-show-node=, =-backlinks=, =-map= (built-in =org-roam-graph= or DOT export, excl =raw/=+index).
+- =C-c n= keybindings (e.g. =C-c n a= switch / =C-c n A= back / a small transient for the browsing commands), which-key labels; profile-level + edit-path ERT tests + =/review-code=.
+
+** Step 3 and the LLM-Wiki layer — deferred
+
+Separate specs. See [[*vNext][vNext]].
+
+* Test strategy
+
+- *CLI / write path:* a write with the remote unreachable still commits locally and does *not* error the agent (push deferred); =flock= serializes concurrent =remember=; each fatal org-lint check (malformed drawer, missing/dup =:ID:=, invalid required property, missing =#+title:=, unparseable org) rejects the commit while a style warning does not; and — the safety boundary — =remember= aborts the commit when the full =ai-kb lint= fails (stale index, broken link, leaked secret in =raw/=), not only on node org-lint.
+- *Index:* regeneration from a fixture KB produces the expected entries; a node added out-of-band appears only after regeneration (proves no drift); =lint --index= flags a missing/stale entry.
+- *Lint gates:* a node missing =:SUMMARY:= (or any required property) fails =ai-kb lint=; the credential scan rejects a secret in a node or =raw/= text file and skips binaries.
+- *query contract:* =ai-kb query --json= returns the specified fields (incl. match reason), exit codes, and =raw/= only as source refs on a fixture KB; a title match outranks a body-only match, with recency only breaking ties (an old preference is not buried under a newer body-only hit).
+- *Index is not a backlink source:* a node referenced only by =index.org= still reports as an orphan in =curate=; the index contains no =[[id:...]]= links.
+- *Push observability:* a simulated push failure is recorded to the state file and surfaced by =ai-kb doctor= / =ai-kb status= ("ahead"/"push failed").
+- *Link recipes* (fixture KB): backlink-by-grep (excluding =raw/= + index) and forward-link-by-grep return correct sets.
+- *Step 2 profile:* switch installs the ai-kb profile and switch-back restores personal *exactly* — completed-task hook, agenda/refile finalize hook, dailies, and capture templates all untouched by ai-kb while switched; a save in an ai-kb buffer runs the index+lint+commit sequence (and a bad save surfaces the lint failure rather than committing); startup re-asserts personal state after a simulated abnormal exit.
+- *Performance* (=:perf= tag): fixture KBs at 100 and 1,000 nodes; assert =index=, =query=, =lint=, and =remember= stay under a stated time budget (catches an accidental per-check Emacs startup or an O(n^2) scan early).
+- *Provisioning* (bats): =setup-ai-kb.sh= idempotent; seeds a node with a valid =:ID:= and =:SUMMARY:=; =doctor= passes on a freshly-provisioned repo.
+
+* Scaling path (planned, not built)
+
+- v1: =rg= over org files + a generated =index.org=.
+- v1.5: =ai-kb query= grows richer ranking over title/tags/properties/body.
+- vNext: a local BM25/vector tool (=qmd= or similar) over the nodes, preserving links; no embeddings in v1.
+
+* Review dispositions
+
+Everything not listed was accepted as written and woven in. Listed: modified, rejected, or owner-deferred recommendations, with reasons.
+
+- *Review 2 core reframe → MODIFIED (scope).* v1 is the memory store, not a full LLM Wiki; per Review 2's own off-ramp and Craig's stated intent.
+- *Review 2 #1 (raw/wiki/schema) → PARTIALLY ADOPTED.* =raw/= for external sources only; compiled =wiki/= + =schema.org= stay vNext (most memories have no external source).
+- *Review 2 #2 (ingest/query/lint) → MODIFIED.* query = index + =rg= (now =ai-kb query=); lint = =ai-kb lint= + curation; org-lint is the write-time validity gate; the heavy ingest pipeline → vNext.
+- *Review 2 #3 (provenance + hashes + numeric confidence) → MODIFIED to provenance-lite,* reconciled with Review 3: adopted =:CREATED_BY:/:CONFIDENCE:(provenance)/:VISIBILITY:/:SOURCE:/:STATUS:=; dropped source *hashes* and numeric confidence scoring (raw-corpus grounding machinery → vNext).
+- *Review 2 #8 (exclude =raw/= from scan) → ADOPTED.*
+- *Review 2 #10 (full visibility taxonomy + lint) → MODIFIED:* v1 = private repo + no-secrets rule + credential lint; four-level taxonomy → vNext.
+- *Review 3 #1 (agent-neutral contract + CLI) → ADOPTED (contract + CLI); cross-agent ADAPTERS deferred to vNext (Craig, 2026-05-24).* The contract lives in the repo and a minimal CLI is the operation surface — justified on Claude-only correctness (atomic safe writes), with neutrality as cheap future-proofing. Codex/Ollama adapters + MCP wait until cross-agent is actually adopted.
+- *Review 3 capability levels =mcp=/=semantic= → DEFERRED.* vNext.
+- *Review 4 #1 (push-failure contract) → ADOPTED,* and strengthened to debounced best-effort push (commit always; push never blocks/fails the agent) — directly informed by the gpg-agent SSH failure observed this session.
+- *Review 4 #2 (index regeneration) → ADOPTED:* generated by =ai-kb index=, never hand-maintained.
+- *Storage location → Option 1 (emacs home) REJECTED* (public mirror leaks); *XDG dedicated private repo ADOPTED;* Syncthing dropped.
+- *Curation full workflow → kept v1-minimal:* read-only =curate --dry-run= ships v1; the interactive merge/split flow is human-gated.
+- *Review 5 (all six) → ACCEPTED.* #1 (the only blocker): =remember= runs the *full* =ai-kb lint= — index freshness, dup IDs, broken links, secret scan — before commit, not just node org-lint. #2: an explicit org-lint fatal-check list (tests target it). #3: push failures are observable (state-file log + =doctor= + startup nudge). #4: =ai-kb query= gets a testable contract (text/=--json=, fixed fields, ordering, exit codes). #5: Step 1 split into 1a (safe write path) / 1b (query/curate/sync/timer/workflow). #6: durable pointers are ID-first (=ai-kb: <Title> (<UUID>)=), not filename-first. Nothing rejected — all six were sound hardening.
+- *Review 6 (all ten + enhancements) → ACCEPTED.* The UX/performance pass, all sound. #1 (the key gap): human Emacs edits use the *same* safety model as agent writes — an ai-kb minor mode whose after-save-hook runs index + full lint + commit under =flock=, so there's one write path, not two. #2: the generated =index.org= is invisible to backlink/orphan logic (excluded from the scan; its references are plain =Title (UUID)= text, not =id:= links). #3: a required =:SUMMARY:= property, so the index/query rebuild from properties without inferring or calling an LLM. #4: =ai-kb query= ranks lexically (title > tag/project/status > summary > body) with recency only as a tie-break, and returns a match reason. #5: performance budgets (100/1,000-node fixtures) + lint+index in one Emacs invocation + =emacsclient=-preferred-with-batch-fallback; the full-lint gate stays, with a cheap/full split held in reserve. #6: switch installs a full org-roam *profile* (dailies, templates, find wrappers, agenda/refile + completed-task hooks all rescoped), not a two-variable swap. #7/#8: a first-class browsing surface (=dashboard/find-node/search/show-node/backlinks/map=), map via built-in =org-roam-graph= or DOT export with =graphviz= in =doctor=. #9: a =raw/= size/type policy (bounded excerpt default, =raw/files/= for large, text-only secret scan, size reporting in =doctor=/=curate=). #10: sync only when stale. Enhancements: =ai-kb show=/=backlinks=/=status= CLI helpers and the generated-files-ignored rule, all folded in.
+
+* Agreed decisions
+
+- Building from the rulesets session is sanctioned cross-project work (Craig, 2026-05-24).
+- ai-kb is intentionally global; the one sanctioned exception to =cross-project.md=.
+- Scope: memory store v1; LLM Wiki deferred.
+- Storage: dedicated private git repo, XDG path, no Syncthing.
+- Write path: commit always, push best-effort/non-blocking/debounced; safe-fetch before; =flock=.
+- Operations are an agent-neutral contract fronted by a minimal =ai-kb= CLI; destructive ops human-only.
+- Cross-agent is not a near-term goal (Craig, 2026-05-24): v1 ships the Claude adapter; other-agent adapters + MCP are deferred to vNext. The contract stays neutral in shape so they are additive later.
+- *Resolved 2026-05-24:* store path = =~/.local/share/ai-kb= (XDG); CLI = a shell wrapper calling =emacs --batch= for the org-lint/sync steps; push = a background =systemd --user= timer (~15 min, push only if ahead), commits always local; curation = node-count trigger surfaced by =ai-kb doctor= + startup, workflow at =~/code/rulesets/.ai/workflows/ai-kb-curate.org=.
+
+* Open decisions
+
+Architecture is decided. These implementation choices are now settled with build-time defaults (2026-05-24); the numeric ones in the first two are starting points to calibrate against the real repo and machine, not invariants.
+
+- [X] *Concrete limits.* Raw excerpt soft cap ~2,000 words (≈16 KB); anything larger is captured as a small pointer-stub plus the full file under =raw/files/=, and only on explicit request. =curate --dry-run= flags any =raw/= file over 256 KB as "unusually large." Curation nudge fires at 150 nodes, then re-fires every +50, tracked by =:LAST_CURATED:= rotation.
+- [X] *Performance budgets* (=:perf= fixtures; one =emacsclient= round-trip assumed, batch fallback ≈ +1s; calibrate, don't treat as invariants): =index= 100 < 0.5s / 1,000 < 3s; =query= 100 < 0.2s / 1,000 < 1s; =lint= 100 < 1s / 1,000 < 6s; =remember= (write + index + full lint, remote mocked) 100 < 1.5s / 1,000 < 8s; =sync= 100 < 2s / 1,000 < 15s. A miss is a *signal* (an accidental per-check Emacs startup, an O(n²) scan), surfaced for investigation, not an automatic build failure.
+- [X] *Lexical scoring weights.* A node's score is the sum of the weight of each field that matches, counted once per field: title 100, tag/project/status 50 each, summary 20, body 5. No term-frequency weighting in v1 — a field either matches or it doesn't. Recency tie-break: when scores are equal, the higher =:UPDATED:= wins.
+- [X] *Map implementation.* Built-in =org-roam-graph= first — the profile's =org-roam-file-exclude-regexp= already keeps =raw/= and =index*.org= out of the db, so the graph inherits the right scope for free, and it is the least code. A custom DOT export is the fallback only if project/tag/status *filtering* proves necessary (=org-roam-graph= can't filter), which is a small additive step on top.
+- [X] *After-save failure UX.* The save always writes to disk and the buffer stays fully editable — never read-only, never blocked. The pipeline runs after the write; on lint failure it *does not commit*, writes the findings to a =*ai-kb-lint*= buffer (popped to, not focus-stealing), and the uncommitted-failing state shows in the modeline + dashboard. Craig fixes and re-saves; a clean save commits. A briefly saved-but-uncommitted file is the intended state, not a trap.
+- [X] *After-save recursion guard.* Two layers. (a) The =ai-kb= minor mode's activation predicate excludes =index*.org= and =raw/=, so generated and captured files never carry the hook. (b) The pipeline binds a re-entrancy flag (=cj/ai-kb--in-pipeline=) that the after-save-hook checks and early-returns on, so programmatic =index.org= regeneration and the commit-time write can't retrigger it. Index regeneration also prefers =write-region= over =save-buffer= to avoid the hook entirely.
+
+* vNext
+
+Each is valuable but out of v1 scope; the v1 bar is token-efficient, safe, and fully recoverable. Reasons given so a future reader need not re-litigate.
+
+- *Step 3 — migrate =.ai/sessions/= + workflows into ai-kb.* Its own spec. *Why not v1:* moving working systems is a migration with its own tradeoffs; prove ai-kb first.
+- *Other-agent adapters (Codex, Ollama) + MCP server.* *Why not v1:* cross-agent is not a near-term goal (Craig, 2026-05-24). The contract is already repo-resident and neutral in shape, so adapters are purely additive when a second agent is actually adopted. Ollama specifically will need a host wrapper (run =ai-kb query= before the turn; =remember --confirm= human-gated) since a model runtime won't curate on its own.
+- *Compiled =wiki/= layer + =schema.org=, source hashes, numeric confidence.* *Why not v1:* pay off only with a substantial external-source corpus to compile and drift-check; v1 captures sources selectively under =raw/= already.
+- *Formal ingest pipeline.* *Why not v1:* the external-corpus workflow that triggers the wiki layer; premature without it.
+- *Semantic / embedding retrieval, =qmd=.* *Why not v1:* find-by-meaning pays off above ~hundreds of nodes; =rg= + index is faster and dependency-free below that.
+- *Event-sourced JSONL catalog + SQLite projection; typed-link graph traversal + content-addressed spans.* *Why not v1:* Nexus-scale infrastructure; org-roam's db + grep cover v1, and clean links/properties now let this be built later without rewriting nodes.
+- *Plan library / operator-DAG execution* (AgenticScholar, Plan*RAG). *Why not v1:* the near-term lesson is only "don't hide retrieval procedure in prose" — met by the =ai-kb query= contract; multi-hop planning waits.
+- *=log.org= op-log.* *Why not v1:* git history already records every write; add later if the git log is too coarse.
+- *Full =:VISIBILITY:= taxonomy + public/private split.* *Why not v1:* the private repo + no-secrets rule cover the floor.
+- *Full agentic-knowledgebase vision* (project hubs; person/decision/thread/meeting/problem/runbook types; =cj/agent-*= commands). *Why not v1:* a much larger product; ai-kb is its first slice.
+- *Live dual-roam browsing* (no switch). *Why not v1:* org-roam supports one active db per session today.
+
+* Relationship to existing mechanisms
+
+- *Per-project claude memory (T2)* — stays the session-recall layer; shrinks to an index pointing into ai-kb (T3) for significant items.
+- *.ai/notes.org and .ai/sessions/* — unchanged in v1 (migration is deferred Step 3).
+- *Personal org-roam (recipes, etc.)* — never touched; reached by switching.
+- *agentic-knowledgebase.org* — the broader vision; ai-kb is its first concrete slice.
diff --git a/docs/specs/ai-vterm-spec-superseded.org b/docs/specs/ai-vterm-spec-superseded.org
new file mode 100644
index 000000000..0b6bfb86c
--- /dev/null
+++ b/docs/specs/ai-vterm-spec-superseded.org
@@ -0,0 +1,163 @@
+:PROPERTIES:
+:ID: 3abd0270-e87c-42b7-9b3a-ef60300db99d
+:STATUS: superseded
+:END:
+#+TITLE: Design: ai-vterm — in-Emacs Claude launcher
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-07
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft.
+
+* Problem
+
+Claude Code currently launches outside Emacs via the =ai= shell script, which builds candidate projects from =~/.emacs.d=, =~/code/*=, =~/projects/*= (anything with =.ai/protocols.org=), opens each in a tmux window, and runs =claude "Read .ai/protocols.org and follow all instructions."= per project. The shell-out pulls focus to a terminal, and tmux's horizontal split is the wrong shape for a code-on-left, Claude-on-right reading layout.
+
+The in-Emacs alternative today is =vterm-toggle= at F12, which uses a horizontal bottom split via =display-buffer-at-bottom=. No project picker, no per-project session model, no vertical split.
+
+Building this in Emacs eliminates the context switch and gives the side-by-side layout that matches how the work is actually read.
+
+* Non-Goals
+
+- Replicating the =ai= script's git prep / auto-pull. Phase A.0 of the startup workflow already handles pulls at session start.
+- A multi-project session switcher with its own UI. =consult-buffer= and the buffer list already navigate between =claude [...]= buffers.
+- Replacing =vterm-toggle= at F12. The existing bottom-split flow stays for non-AI shells.
+- Tab-bar or frame-per-project layouts.
+- Auto-launching tmux inside the AI vterm. Claude under tmux adds a session-management layer for no benefit here.
+
+* Approaches Considered
+
+** Recommended: wrap =vterm= directly with per-project named buffers + a parallel display rule
+
+A new module =modules/ai-vterm.el= adds a command that picks a Claude-template project, opens (or reuses) a vterm buffer named =claude [<basename>]=, and lets a new =display-buffer-alist= entry route any buffer matching that prefix to a right-side window. Multiple projects produce multiple coexisting buffers, all sharing the same right-side slot. Switching among them is a buffer-switch, not a kill-and-recreate.
+
+Pros:
+- Same package (=vterm=) as the existing config.
+- Per-project buffers run simultaneously without conflict.
+- Right-side placement is one =display-buffer-alist= entry.
+- Existing windmove (Shift-arrows) handles code↔Claude focus toggling. =buffer-move= (C-M-arrows) handles side-swap. Neither needs new bindings.
+
+Cons:
+- Re-implements toggle/show-hide logic that =vterm-toggle= would handle for free. Acceptable because =vterm-toggle= is built around one toggle-able buffer, and the per-project model is what's wanted.
+
+** Rejected: wrap =vterm-toggle=
+
+=vterm-toggle='s contract is one buffer toggled visible/hidden. Per-project buffers running simultaneously is outside that contract. Wrapping it would mean fighting the abstraction.
+
+** Rejected: project-per-tab via =tab-bar-mode=
+
+Each project gets its own tab. Matches the =ai= / tmux model cleanly, but adds tab-bar UI that isn't in current use. Bigger lifestyle change for a one-window task.
+
+** Rejected: frame-per-project
+
+Each Claude session opens in a new Emacs frame. Hyprland-native, clean isolation, but frame creation under Wayland has historical jank, and it breaks the easy windmove flow between code and Claude.
+
+** Rejected: window-configuration-per-project
+
+Save and restore named window configs (code buffers + Claude vterm together). Preserves the surrounding thinking environment, but window configs go stale when buffers die, and it adds a parallel mechanism to project.el. Overkill for v1.
+
+* Design
+
+** Architecture
+
+New module =modules/ai-vterm.el=. Required after =eshell-vterm-config= in =init.el= so =vterm= is loaded.
+
+Components:
+
+| Function | Kind | Responsibility |
+|----------+------+----------------|
+| =cj/--ai-vterm-candidates= | pure | Walks =~/.emacs.d=, =~/code/*=, =~/projects/*=; returns abs paths containing =.ai/protocols.org= |
+| =cj/--ai-vterm-pick-project= | interactive helper | =completing-read= over candidates; returns picked path |
+| =cj/--ai-vterm-buffer-name= | pure | =(format "claude [%s]" basename)= |
+| =cj/--ai-vterm-show-or-create= | internal | Given dir + name: display existing buffer, or create vterm + send claude command |
+| =cj/ai-vterm= | interactive entry | Composes picker + show-or-create |
+
+The =display-buffer-alist= entry is added at module load:
+
+#+begin_src emacs-lisp
+(add-to-list 'display-buffer-alist
+ '("\\`claude \\["
+ (display-buffer-in-side-window)
+ (side . right)
+ (window-width . 0.5)
+ (dedicated . t)))
+#+end_src
+
+** Data Flow
+
+On =M-x cj/ai-vterm=:
+
+1. Pick a project via =completing-read=. Display in =~/relative= form. Return absolute path.
+2. Compute buffer name: =claude [<basename-of-dir>]=.
+3. Branch:
+ - *Buffer exists with live process* → =display-buffer= it. Side-window rule routes it to the right slot.
+ - *Buffer exists, dead process* → kill it (log last 200 chars to =*Messages*=), then fall through to create.
+ - *No buffer* → =let=-bind =default-directory= to picked dir and =vterm-buffer-name= to computed name; call =(vterm)=. After process is live, send =claude "Read .ai/protocols.org and follow all instructions."= via =vterm-send-string= + =vterm-send-return=.
+4. =select-window= on the displayed window so point lands in Claude. =C-u= prefix shows without selecting.
+
+After this, all navigation is handled by existing global bindings: Shift-arrows (windmove) for focus, C-M-arrows (=buffer-move=) for directional side-swap.
+
+** Error Handling
+
+| Case | Response |
+|------+----------|
+| Picker cancelled (=quit=) | Silent no-op |
+| No candidates found | =user-error= naming the search roots |
+| Picked dir disappeared between scan and launch | =user-error= naming the path |
+| Existing buffer with dead process | Kill + recreate; log last 200 chars |
+| Side-window already showing a different =claude [...]= | =display-buffer= swaps which buffer occupies the slot; hidden one keeps running |
+| =vterm= not installed | Module fails to load loudly (no graceful degradation) |
+
+** Per-project tmux sessions
+
+The launch command sent to a fresh AI-vterm shell is
+
+#+begin_example
+tmux new-session -A -s <basename> -c <dir> '<claude-cmd>; exec bash'
+#+end_example
+
+- =-A= reattaches to an existing session of the same name instead of creating a new one. So a second F9 on the same project after an Emacs crash brings the running Claude back without spawning a duplicate.
+- =-s <basename>= names the session after the project's directory basename. =tmux ls= shows the active sessions by project name.
+- =-c <dir>= sets the start directory for new sessions (ignored on attach).
+- =exec bash= tails the shell command so the tmux window survives Claude exiting -- the session stays alive with a bare prompt for recovery, and reattach is unaffected.
+
+** Tmux Auto-Launch Suppression
+
+The existing =cj/vterm-launch-tmux= on =vterm-mode-hook= types =tmux\n= unconditionally for any vterm buffer. AI-vterm =let='s a dynamic =cj/--ai-vterm-suppress-tmux= flag around =(vterm)= so the hook skips its bare =tmux\n= and the project-named launch command (above) runs instead.
+
+** Testing
+
+Pure helpers tested against real inputs:
+
+- =cj/--ai-vterm-buffer-name= — Normal, Boundary (trailing slash, dot-prefix dirs, spaces in basenames), Error (degenerate paths).
+- =cj/--ai-vterm-candidates= — temp directory tree built with =make-temp-file= + =make-directory=, fake =.ai/protocols.org= markers. Assert returned paths, ignored entries.
+
+Internal with mocked boundary:
+
+- =cj/--ai-vterm-show-or-create= — =cl-letf= on =vterm= to skip process spawn; assert buffer name, =default-directory=, claude argv via captured =vterm-send-string= calls. Two branches (exists vs creates) tested with mocked =process-live-p=.
+
+Display rule:
+
+- After =add-to-list=, =display-buffer= on a buffer named =claude [test]= lands in a window with =(window-parameter w 'window-side) = 'right=.
+
+Test files:
+
+- =tests/test-ai-vterm--candidates.el=
+- =tests/test-ai-vterm--buffer-name.el=
+- =tests/test-ai-vterm--show-or-create.el=
+- =tests/test-ai-vterm--display-rule.el=
+
+Smoke test (=:slow= tag, excluded from default suite): launch against a fixture, verify live process.
+
+* Open Questions
+
+- [ ] Default split width — 50/50 vs 60/40 weighted to code. Starting with 50/50.
+- [X] Keybinding — F9. Replaces the prior =cj/toggle-gptel= binding on F9; gptel moves to C-F9.
+
+* Next Steps
+
+- TDD implementation in this order: =buffer-name= → =candidates= → =show-or-create= → display rule → interactive entry.
+- Wire into =init.el= after =eshell-vterm-config=.
+- Pick a keybinding once the command is shipped.
diff --git a/docs/specs/cache-helper-design-spec-implemented.org b/docs/specs/cache-helper-design-spec-implemented.org
new file mode 100644
index 000000000..27c818dcb
--- /dev/null
+++ b/docs/specs/cache-helper-design-spec-implemented.org
@@ -0,0 +1,169 @@
+:PROPERTIES:
+:ID: 647c5101-21c2-47bb-aaa7-72c757f45fb7
+:STATUS: implemented
+:END:
+#+TITLE: Cache Helper Design Addendum
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-10
+
+* Status
+
+Phase 5 design addendum to [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]]. Specifies the cache API to extract before any code moves.
+
+* Problem
+
+Two modules carry a parallel cache implementation today:
+
+- =modules/org-agenda-config.el= caches the agenda file list.
+- =modules/org-refile-config.el= caches refile targets.
+
+Both share the same shape (lines map between modules):
+
+| Element | Purpose |
+|---------|---------|
+| =VAR-cache= | the cached value |
+| =VAR-cache-time= | float-time when last built |
+| =VAR-cache-ttl= | seconds to retain (default 3600) |
+| =VAR-building= | non-nil while an async build is in progress |
+| =cj/build-VAR (&optional force-rebuild)= | "use cache if valid, else rebuild" |
+
+The build function does:
+
+1. Check whether cache is valid: cache and cache-time set, FORCE-REBUILD nil, age < TTL.
+2. If valid: assign to the consumer's variable, log cache-hit, return.
+3. If a background build is running: log "waiting...", continue (no second build).
+4. Otherwise: set building flag, run the rebuild closure inside =condition-case=, on success update cache+time, on error log message; the =unwind-protect= clears the building flag.
+
+The consumer's variable (e.g. =org-agenda-files=, =org-refile-targets=) is updated as a side effect.
+
+A separate cache pattern exists in =modules/modeline-config.el= for VC info, but it's *buffer-local*, *key-based* (the file path), and not TTL-based. It will not migrate to the same helper -- see "Out of scope" below.
+
+* Goals
+
+- One source of truth for the TTL+building build pattern.
+- Consumers shrink from ~30 lines of cache plumbing to a single =cj/cache-value-or-rebuild= call.
+- Behavior preserved for agenda and refile.
+- Tests cover rebuild / TTL hit / TTL miss / nil cache value / build error / building-flag cleanup.
+
+* Out of scope
+
+- Modeline VC cache (=cj/modeline-vc-cache-*=). Buffer-local + key-based + non-TTL invalidation. Different lifecycle. The spec calls this out explicitly: consider only after global cache behavior is stable. Defer to a future round.
+- Generic memoization. We're not introducing function memoization or LRU. Just the specific "rebuild a long-running computation behind a TTL" pattern.
+
+* API
+
+** =cj/cache-make=
+
+#+begin_src emacs-lisp
+(cj/cache-make &key ttl)
+#+end_src
+
+Return a fresh cache state object. TTL is in seconds; defaults to 3600.
+
+The state is a plist with keys =:value=, =:time=, =:ttl=, =:building=. Consumers store the state in a single =defvar=.
+
+** =cj/cache-valid-p=
+
+#+begin_src emacs-lisp
+(cj/cache-valid-p cache)
+#+end_src
+
+Return non-nil when CACHE has a non-nil value, a non-nil time, and (now - time) < ttl.
+
+** =cj/cache-value-or-rebuild=
+
+#+begin_src emacs-lisp
+(cj/cache-value-or-rebuild cache build-fn
+ &key force-rebuild
+ on-hit
+ on-build-start
+ on-build-success
+ on-build-error)
+#+end_src
+
+The main entry point. Returns the cached value when valid (and FORCE-REBUILD is nil); otherwise calls BUILD-FN to compute a new value, updates CACHE, and returns the result.
+
+The four optional callbacks let the consumer log without the helper printing on its behalf:
+
+- =on-hit (value)= -- the helper found a valid cache.
+- =on-build-start ()= -- about to call BUILD-FN.
+- =on-build-success (value)= -- BUILD-FN returned cleanly.
+- =on-build-error (err)= -- BUILD-FN signaled. After this fires the helper rethrows so the caller sees the error.
+
+The =:building= flag is set before BUILD-FN runs and cleared inside an =unwind-protect= regardless of outcome. The flag is exposed read-only via =cj/cache-building-p= so callers can log "build in progress" without poking at the plist directly.
+
+** =cj/cache-building-p=
+
+#+begin_src emacs-lisp
+(cj/cache-building-p cache)
+#+end_src
+
+Return non-nil when a build is currently in progress on CACHE. Used by callers that want to log "waiting for background build" before invoking =cj/cache-value-or-rebuild=.
+
+** =cj/cache-invalidate=
+
+#+begin_src emacs-lisp
+(cj/cache-invalidate cache)
+#+end_src
+
+Reset the cache to "no value, no time". TTL is preserved.
+
+* Consumer Shape (after migration)
+
+The agenda module's ~30 lines of cache plumbing become roughly:
+
+#+begin_src emacs-lisp
+(defvar cj/--org-agenda-files-cache (cj/cache-make :ttl 3600))
+
+(defun cj/build-org-agenda-list (&optional force-rebuild)
+ "..."
+ (interactive "P")
+ (when (cj/cache-building-p cj/--org-agenda-files-cache)
+ (cj/log-silently "Waiting for background agenda build to complete..."))
+ (let ((files
+ (cj/cache-value-or-rebuild
+ cj/--org-agenda-files-cache
+ (lambda () (cj/--scan-org-agenda-files))
+ :force-rebuild force-rebuild
+ :on-hit (lambda (v) (cj/log-silently
+ "Using cached agenda files (%d files)" (length v)))
+ :on-build-start (lambda () (cj/log-silently
+ "Rebuilding agenda files (slow)..."))
+ :on-build-success (lambda (v) (cj/log-silently
+ "Built agenda files (%d files)"
+ (length v)))
+ :on-build-error (lambda (err) (cj/log-silently
+ "Agenda build failed: %s" err)))))
+ (setq org-agenda-files files)
+ files))
+#+end_src
+
+The =cj/--scan-org-agenda-files= helper holds the slow filesystem walk; the existing in-place expression body moves there with no behavior change.
+
+* Migration order
+
+Per the spec: agenda first, refile second. Each is its own commit:
+
+1. Add =modules/cj-cache.el= with the API and tests. No call-site changes.
+2. Migrate =org-agenda-config.el= to the helper. Verify behavior with the existing async-cache tests.
+3. Migrate =org-refile-config.el= the same way.
+
+* Testing
+
+For the helper itself, =tests/test-cj-cache.el=:
+
+- Normal: hit returns cached value; miss calls BUILD-FN.
+- TTL: build at t=0, request at t=ttl-1 hits; request at t=ttl+1 rebuilds.
+- FORCE-REBUILD wins over a valid cache.
+- nil from BUILD-FN is stored (cache-valid-p returns t). This matches today's behavior -- a build that legitimately produces nil should not loop. *Decision point*: the current implementations actually treat "cache value nil" as "cache invalid" (line 131 of agenda, line 66 of refile both check =(and cache cache-time ...)=). Preserve that to avoid a behavior change: the new helper's =cj/cache-valid-p= treats a nil :value as "not valid". That's the safer default; consumers that need "nil is a real value" can migrate to a sentinel later.
+- :building flag is set during BUILD-FN, cleared after success.
+- :building flag is cleared even when BUILD-FN signals.
+- Each callback fires once per appropriate path (hit / start / success / error).
+
+For the migrated consumers: the existing async-cache and rebuild tests run unchanged after the migration. No new test files for agenda/refile are required as part of Phase 5 itself; they got their tests when the original cache was added.
+
+* Risks
+
+- *Behavior drift on cache-hit logging.* The current code logs "Using cached agenda files (N files)" via =cj/log-silently=. The migration preserves that exact message via =:on-hit=. Verify by tail-ing =*Messages*= during a manual smoke test.
+- *Building-flag leak.* The current code uses =unwind-protect= to clear =VAR-building= even on error. The helper does the same. The test "building flag cleared on error" pins this contract.
+- *Async timer interaction.* Both modules schedule background builds via =run-with-idle-timer=. The migration leaves those scheduling forms in the consumer; only the cache-or-build core moves. No changes to startup timing.
diff --git a/docs/specs/company-to-corfu-migration-spec.org b/docs/specs/company-to-corfu-migration-spec.org
new file mode 100644
index 000000000..a7b059a3b
--- /dev/null
+++ b/docs/specs/company-to-corfu-migration-spec.org
@@ -0,0 +1,328 @@
+:PROPERTIES:
+:ID: 68733ba2-37a7-4a7b-bfaa-b845d82ff1e7
+:STATUS: not-started
+:END:
+#+TITLE: Design: Migrate from Company to Corfu (with prescient integration)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-15
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft.
+
+* Problem
+
+The in-buffer completion stack is built on =company= (=modules/selection-framework.el:192-243=), augmented with =company-quickhelp= (doc popups), =company-box= (icon kinds), and =company-prescient= (smart sorting). The configuration works, but =company= predates the modern =completion-at-point= machinery in Emacs 29+: it maintains its own backend list (=company-backends=) parallel to =completion-at-point-functions= and routes around the built-in protocol.
+
+=corfu= is the modern equivalent. It drives the same UI through =completion-at-point-functions= directly, which means every Emacs mode that already publishes a capf (eglot, elisp-mode, ledger-mode, AUCTeX, etc.) lights up without a custom company backend. The plugin ecosystem (=cape=, =kind-icon=, =corfu-popupinfo=, =corfu-prescient=) covers the remaining gaps: fallback completers, icon kinds, doc popups, and prescient sorting.
+
+This migration replaces the =company= stack with the equivalent =corfu= stack, preserving:
+
+- Global in-buffer completion across prog and text modes.
+- Tab to complete, =C-n=/=C-p= to navigate the candidate list.
+- File-path completion (currently via =company-files=).
+- Keyword completion in programming modes.
+- Doc popups for the selected candidate.
+- Icon kinds in the candidate list.
+- prescient-based smart sorting (recency + frequency + filter).
+- Disabling completion in mail compose buffers.
+- Per-mode prefix length and idle delay tuning where it differs.
+- Mode-specific backends (=company-ledger=, =company-auctex=, =company-shell=).
+
+* Goals
+
+1. =global-corfu-mode= replaces =global-company-mode=, with the same hook timing.
+2. Every current =company-*= package and helper has a corfu-side equivalent or a documented drop.
+3. Per-mode capf customizations (ledger, AUCTeX, eshell, mu4e compose) keep working.
+4. prescient sorting extends from vertico (where it already runs) to corfu via =corfu-prescient=.
+5. No regression in mu4e compose buffers — completion stays disabled there.
+
+* Non-Goals
+
+- Adding new completion sources beyond what =company= already provides. Source tuning is a follow-up.
+- Reworking =eglot= or LSP integration. =corfu= reads =completion-at-point-functions=; eglot already publishes a capf.
+- Touching =vertico=, =marginalia=, =consult=, =embark=, or =orderless=. Those operate on the minibuffer, not the in-buffer completion frontend.
+- Touching the =accent= package's =accent-company= command (=modules/text-config.el:97-99=). The name shares a prefix with =company= by coincidence; it is the package's own function and does not depend on =company-mode=.
+
+* Current State
+
+** Module: =modules/selection-framework.el:192-243=
+
+| What | How |
+|----------------------------+--------------------------------------------------------------------|
+| Global activation | =:hook (after-init . global-company-mode)= |
+| Keymap (active) | tab → complete, =C-n=/=C-p= → next/prev |
+| Backends | =(company-capf company-files company-keywords)= |
+| Idle delay | =2= seconds |
+| Minimum prefix | =2= chars |
+| Show numbers | =t= |
+| Tooltip alignment / flip | annotations aligned, flip when above |
+| Tooltip limit | =10= |
+| Selection wrap | =t= |
+| Require match | =nil= |
+| Global disable modes | =message-mode=, =mu4e-compose-mode=, =org-msg-edit-mode= |
+| Doc popups | =company-quickhelp= (=:config (company-quickhelp-mode)=) |
+| Icon kinds | =company-box= (=:hook (company-mode . company-box-mode)=) |
+| prescient sorting | =company-prescient= (=:config (company-prescient-mode)=) |
+
+** Other modules that touch company
+
+| Module | What |
+|---------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------|
+| =modules/ledger-config.el:44-47= | =company-ledger= backend added to =company-backends= after =ledger-mode= loads |
+| =modules/latex-config.el:44-46= | =company-auctex= with =:init (company-auctex-init)= |
+| =modules/eshell-config.el:163-171= | =company-shell= backend; eshell-mode-hook sets =company-minimum-prefix-length= and =company-idle-delay= to =2= locally, then enables =company-mode= |
+| =modules/mail-config.el:319-333= | =cj/disable-company-in-mu4e-compose= calls =(company-mode -1)= in =mu4e-compose-mode-hook= and =org-msg-edit-mode-hook= |
+| =modules/prog-go.el:41,50= | =(declare-function company-mode "company")= + =(company-mode)= in go-mode-hook |
+| =modules/prog-python.el:28,46= | Same shape for python-mode-hook |
+| =modules/prog-webdev.el:32,47= | Same shape for web-mode-hook |
+
+The three prog-* modules are redundant once =global-company-mode= is on; they will become redundant in the same way once =global-corfu-mode= is on. They can either be deleted outright or rewritten to ensure capfs are wired.
+
+* Target State
+
+** New configuration in =modules/selection-framework.el=
+
+Replace the company block with:
+
+#+begin_src emacs-lisp
+;; ---------------------------------- Corfu ----------------------------------
+;; In-buffer completion built on completion-at-point-functions.
+
+(use-package corfu
+ :demand t
+ :hook (after-init . global-corfu-mode)
+ :bind
+ (:map corfu-map
+ ("<tab>" . corfu-complete)
+ ("C-n" . corfu-next)
+ ("C-p" . corfu-previous))
+ :custom
+ (corfu-cycle t) ; wrap-around selection
+ (corfu-auto t) ; auto-popup like company
+ (corfu-auto-delay 2.0) ; match company-idle-delay
+ (corfu-auto-prefix 2) ; match company-minimum-prefix-length
+ (corfu-count 10) ; match company-tooltip-limit
+ (corfu-quit-no-match 'separator) ; quit only after explicit gap
+ (corfu-preview-current nil) ; no inline preview (closer to company default)
+ :config
+ ;; History so frequently-used candidates float up across sessions.
+ (with-eval-after-load 'savehist
+ (corfu-history-mode 1)
+ (add-to-list 'savehist-additional-variables 'corfu-history))
+ ;; Mirror company-global-modes = (not message-mode mu4e-compose-mode
+ ;; org-msg-edit-mode): corfu has no built-in exclusion list, so the
+ ;; mail-config hook below toggles corfu-mode off in those buffers.
+ )
+
+;; Doc popups for the selected candidate (company-quickhelp equivalent).
+(use-package corfu-popupinfo
+ :ensure nil ; ships with corfu
+ :after corfu
+ :hook (corfu-mode . corfu-popupinfo-mode)
+ :custom
+ (corfu-popupinfo-delay '(0.5 . 0.2))) ; (initial . subsequent)
+
+;; Icon kinds (company-box equivalent).
+(use-package kind-icon
+ :after corfu
+ :custom
+ (kind-icon-default-face 'corfu-default)
+ :config
+ (add-to-list 'corfu-margin-formatters #'kind-icon-margin-formatter))
+
+;; Cape: extra capfs (file paths, keywords, dabbrev, dict) so corfu
+;; covers the cases company-files / company-keywords used to handle.
+(use-package cape
+ :demand t
+ :config
+ ;; Order matters: file paths first (most specific), then keywords,
+ ;; then dabbrev (buffer words) as the catch-all.
+ (add-to-list 'completion-at-point-functions #'cape-file)
+ (add-to-list 'completion-at-point-functions #'cape-keyword)
+ (add-to-list 'completion-at-point-functions #'cape-dabbrev))
+#+end_src
+
+The existing =prescient= and =vertico-prescient= use-package blocks stay. =company-prescient= is replaced with =corfu-prescient=:
+
+#+begin_src emacs-lisp
+(use-package corfu-prescient
+ :demand t
+ :after (corfu prescient)
+ :config
+ (corfu-prescient-mode))
+#+end_src
+
+** Setting / Package Translation Table
+
+| Company setting / package | Corfu equivalent |
+|------------------------------+--------------------------------------------------------|
+| =global-company-mode= | =global-corfu-mode= |
+| =company-backends= | =completion-at-point-functions= (set by modes + cape) |
+| =company-capf= | built-in (corfu reads capf directly) |
+| =company-files= | =cape-file= |
+| =company-keywords= | =cape-keyword= |
+| =company-idle-delay= | =corfu-auto-delay= (when =corfu-auto= is =t=) |
+| =company-minimum-prefix-length= | =corfu-auto-prefix= |
+| =company-tooltip-limit= | =corfu-count= |
+| =company-selection-wrap-around= | =corfu-cycle= |
+| =company-require-match= | =corfu-quit-no-match='separator= (closest equivalent) |
+| =company-show-numbers= | no direct equivalent; drop (rarely used) |
+| =company-tooltip-align-annotations= | corfu does this by default |
+| =company-tooltip-flip-when-above= | corfu repositions automatically |
+| =company-global-modes= (excludes) | per-mode hook toggling =corfu-mode= off |
+| =company-quickhelp= | =corfu-popupinfo= (ships with corfu) |
+| =company-box= | =kind-icon= |
+| =company-prescient= | =corfu-prescient= |
+| =company-ledger= | =ledger-mode='s built-in capf (Emacs 28+) -- see below |
+| =company-auctex= | AUCTeX's built-in capf + =cape-tex= -- see below |
+| =company-shell= | =cape-keyword= + eshell's own pcomplete via capf |
+
+* Migration Steps
+
+Order matters: package install → core swap → per-module fixups → cleanup.
+
+** Step 1: install corfu-side packages
+
+Add to the package install list (ELPA pulls these in via use-package):
+
+- =corfu=
+- =cape=
+- =kind-icon=
+- =corfu-prescient=
+
+(=corfu-popupinfo= ships inside =corfu= and does not need a separate install.)
+
+** Step 2: rewrite =modules/selection-framework.el=
+
+Replace lines 192-226 (the three =company-*= use-package blocks) with the corfu / cape / corfu-popupinfo / kind-icon blocks above. Replace line 240-243 (=company-prescient=) with =corfu-prescient=. Section headers update from "Company" → "Corfu".
+
+** Step 3: rewrite mail-compose disabling (=modules/mail-config.el:319-333=)
+
+Replace the =cj/disable-company-in-mu4e-compose= helper:
+
+#+begin_src emacs-lisp
+(defun cj/disable-corfu-in-mu4e-compose ()
+ "Disable corfu in mu4e compose buffers (and org-msg-edit-mode).
+Mail composition reads more naturally without auto-popups."
+ (corfu-mode -1))
+
+(add-hook 'mu4e-compose-mode-hook #'cj/disable-corfu-in-mu4e-compose)
+(with-eval-after-load 'org-msg
+ (add-hook 'org-msg-edit-mode-hook #'cj/disable-corfu-in-mu4e-compose))
+#+end_src
+
+Also disable in =message-mode= (which company excluded via =company-global-modes=) by adding a hook:
+
+#+begin_src emacs-lisp
+(add-hook 'message-mode-hook #'cj/disable-corfu-in-mu4e-compose)
+#+end_src
+
+(The function name still says "mu4e-compose" but covers all three modes via the same toggle. Rename to =cj/--disable-corfu-in-mail= if that bothers; cosmetic.)
+
+** Step 4: rewrite =modules/ledger-config.el=
+
+Drop =company-ledger=. =ledger-mode= ships =ledger-complete-at-point= and registers it on =completion-at-point-functions= when the mode loads. Verify with =M-x describe-variable RET completion-at-point-functions RET= inside a ledger buffer after the migration. No new code needed unless verification shows the capf isn't being registered, in which case add a local capf push in =ledger-mode-hook=.
+
+** Step 5: rewrite =modules/latex-config.el=
+
+Drop =company-auctex= and its =(company-auctex-init)= call. AUCTeX 13+ publishes its own capf via =TeX-mode='s setup. =cape-tex= covers LaTeX macro / symbol completion as a fallback. Add to the LaTeX config:
+
+#+begin_src emacs-lisp
+(with-eval-after-load 'tex-mode
+ (add-hook 'TeX-mode-hook
+ (lambda ()
+ (add-to-list 'completion-at-point-functions #'cape-tex))))
+#+end_src
+
+** Step 6: rewrite =modules/eshell-config.el:163-171=
+
+Drop =company-shell= and the eshell-mode-hook =company-mode= activation. Replace with per-mode capf wiring:
+
+#+begin_src emacs-lisp
+(add-hook 'eshell-mode-hook
+ (lambda ()
+ ;; eshell publishes pcomplete-completions-at-point. cape
+ ;; wraps pcomplete so corfu picks it up.
+ (add-to-list 'completion-at-point-functions
+ (cape-capf-buster #'pcomplete-completions-at-point))
+ (corfu-mode 1)))
+#+end_src
+
+The =cape-capf-buster= wrapper invalidates pcomplete's cache between completion calls; without it, eshell completion staleness shows.
+
+** Step 7: delete the three prog-* =company-mode= calls
+
+In =modules/prog-go.el=, =modules/prog-python.el=, and =modules/prog-webdev.el=:
+
+- Remove =(declare-function company-mode "company")=.
+- Remove =(company-mode)= from the mode hook (=global-corfu-mode= covers it).
+
+If any of the three modes needs a mode-specific capf override (most don't; eglot / language-server modes publish their own), add it in place of the deleted call.
+
+** Step 8: rename section header in selection-framework.el
+
+The header at line 189 (=;; ---- Company ----=) becomes =;; ---- Corfu ----=. Cosmetic but worth doing in the same change for grep-ability.
+
+** Step 9: byte-compile and uninstall company packages
+
+After the rewrite is green:
+
+- =M-x package-delete= on =company=, =company-quickhelp=, =company-box=, =company-prescient=, =company-ledger=, =company-auctex=, =company-shell=.
+- Confirm =M-x list-packages= shows none of them as installed.
+- Run =make clean && make compile= to refresh =.elc=.
+
+* Testing
+
+** Unit / integration
+
+- =tests/test-selection-framework-corfu.el= (new)
+ - =corfu= is required and =global-corfu-mode= is on after init.
+ - =completion-at-point-functions= includes =cape-file=, =cape-keyword=, =cape-dabbrev= in the global value.
+ - =corfu-prescient-mode= is enabled.
+- =tests/test-mail-config-corfu-disable.el= (new)
+ - Visiting a buffer in =mu4e-compose-mode= and =message-mode= leaves =corfu-mode= disabled.
+- Update =tests/test-ledger-config.el= and =tests/test-latex-config.el= (if they exist) to assert the relevant capf is registered.
+
+** Manual verification
+
+Run each:
+
+1. Open an =elisp= file, type =mes= → corfu popup shows =message=, =message-box=, etc. Tab completes.
+2. Open a =python= file with an eglot-attached pyright, type a partial identifier → capf candidates appear via corfu.
+3. Open a =.ledger= file, type a partial account → ledger's own capf surfaces matches.
+4. Open a =.tex= file, type =\beg= → AUCTeX capf shows =\begin{}=, etc.
+5. Open eshell, type =cd ~/=,/= → cape-pcomplete capf completes paths.
+6. =C-x m= or open mu4e compose → no popup; =corfu-mode= reports as off in the mode line.
+7. Recently-completed candidates float to the top after a few uses (prescient).
+8. Type a partial filename in a Lisp buffer (=/etc/pas=) → =cape-file= completes =/etc/passwd= path.
+
+** Regression watch
+
+- =accent-company= (=C-`= in text modes) still opens its own popup; it doesn't depend on =company-mode=.
+- =eglot= integration: capf priority should be eglot first, cape* last. If eglot completions get crowded out by =cape-dabbrev=, switch =cape-dabbrev= to a buffer-local addition only in modes that lack a richer capf.
+
+* Risks
+
+| Risk | Mitigation |
+|---------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------|
+| AUCTeX's built-in capf doesn't actually fire (some AUCTeX versions need a manual nudge) | Step 5 also adds =cape-tex=; verify in step-tested .tex file. |
+| =cape-dabbrev= clutters language-server completions | Make =cape-dabbrev= per-mode (text modes only) if regression appears; trivial to scope down. |
+| ledger-mode capf is unregistered on first buffer open | If verification fails, add =ledger-mode-hook= that pushes =ledger-complete-at-point= onto the capf list. |
+| eshell pcomplete cache staleness | =cape-capf-buster= in step 6 invalidates between calls. |
+| prescient sort order resets | =corfu-history-mode= + =savehist-additional-variables= preserves across sessions; prescient stays for the frequency/recency weighting. |
+| Some modes (rare) only support company backends, never wrote a capf | Discovered case-by-case during step 7 verification. Worst case: keep =company= around in a tiny scope for that one mode, which defeats the migration -- unlikely. |
+
+* Rollback
+
+The change lives in one commit (or one branch). Revert restores company + the per-module integrations. =package-install= the deleted =company-*= packages back. Idempotent.
+
+* Effort estimate
+
+M (1 hour to 1 day). The rewrite of =selection-framework.el= is ~50 lines and mechanical. The per-module fixups are 5-15 lines each across six files. Testing the per-mode capfs is where the time goes.
+
+* Open questions
+
+- Keep =cape-dict= for spell-style completion in text modes? Out of scope for the migration but a natural follow-up. Decide after the base swap lands.
+- Switch eshell to =eat= or =eshell-toggle= as part of this? No — out of scope.
diff --git a/docs/specs/coverage-spec-implemented.org b/docs/specs/coverage-spec-implemented.org
new file mode 100644
index 000000000..65734fb3d
--- /dev/null
+++ b/docs/specs/coverage-spec-implemented.org
@@ -0,0 +1,210 @@
+:PROPERTIES:
+:ID: 7d7f4486-fad7-4f0a-bd9a-775bd4cd8f7e
+:STATUS: implemented
+:END:
+#+TITLE: Design: Coverage Reporting
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-04-22
+
+* Status
+
+Implemented for Elisp.
+
+The shipped path is local-first: =make coverage= produces
+=.coverage/simplecov.json= with Undercover, and =cj/coverage-report= reads that
+artifact to show either diff-aware coverage or a whole-project summary from
+Emacs. Python, TypeScript, and Go backends remain future work.
+
+* Problem
+
+Before this work, there was no quick way to answer "are the lines I just
+changed actually covered by tests?" Line-level coverage for the *whole*
+project was also missing, and there was no local artifact to inspect.
+
+The primary user-facing need is the first one: point-in-time feedback on
+in-flight changes, triggered from Emacs. The implemented system also supports a
+whole-project summary and writes a local SimpleCov JSON artifact.
+
+The tooling should be pluggable so the same workflow covers Elisp today and Python, TypeScript, and Go later — without rebuilding the UI for each language.
+
+* Non-Goals
+
+- Continuous in-buffer overlays (fringe marks, line highlights). Parked over performance concerns.
+- Mutation testing or any signal other than line coverage.
+- CI integration beyond emitting a simplecov JSON artifact. No coveralls, no GitHub Actions wiring.
+- Shadowing or replacing existing test-running commands (=make test=, =make test-file=, etc.).
+
+* Approaches Considered
+
+** Recommended: diff-aware report with pluggable backends
+
+Core engine reads a simplecov JSON file, shells to ~git diff~ at a selectable scope, intersects, and displays the result in a compilation-mode-derived buffer. Language-specific "backends" each produce simplecov in their own way and register themselves with the core.
+
+*Pros:* Directly serves the primary use case. Simplecov is broadly supported across language coverage tools, and Undercover's ~:merge-report t~ option works for simplecov (but not for LCOV), which is essential for the per-file coverage-run strategy. Compilation-mode inheritance gives free =next-error= / =previous-error= navigation.
+
+*Note on format:* An earlier draft of this design used LCOV. That was changed to simplecov after discovering that Undercover's LCOV writer does not implement report-merging — per-file coverage runs would require custom merge logic or an external ~lcov~ tool. Simplecov's native merge-report support made it the cleaner fit without changing anything about the pluggable backend story.
+
+*Cons:* More code than a "just run coverage and read the output" approach. Backend registry adds one layer of indirection (small — ~30 lines).
+
+** Rejected: non-interactive pre-commit hook
+
+Would run coverage on every commit and report uncovered-changed-lines to stderr. Literal fit for the use case but adds a long delay to every commit and offers no way to inspect non-staged scopes.
+
+** Rejected: coverage as a =review-code= skill criterion
+
+Would fold coverage into the existing pre-commit review skill. Clean in principle, but couples =review-code= to Emacs-specific tooling and makes ad-hoc inspection (outside a review) awkward.
+
+** Rejected: mutation testing instead of line coverage
+
+Stronger signal than coverage but minutes-to-hours runtime on the current 265-file suite, and no polished Elisp tool exists. Different conversation.
+
+* Design
+
+** Architecture
+
+Three files:
+
+- =modules/coverage-core.el= — engine + backend registry + user-facing command. Language-agnostic.
+- =modules/coverage-elisp.el= — the initial backend. Registers itself on load.
+- (Future) =modules/coverage-python.el=, =coverage-typescript.el=, =coverage-go.el= — each ~30 lines, self-registering.
+
+=init.el= requires the core and the active backends.
+
+*** Elisp coverage producer
+
+For the Elisp backend, =make coverage= is the only supported producer of the
+coverage artifact. It removes stale compiled files for instrumented sources,
+then runs each unit test file in its own batch Emacs process. Before loading
+the test file, the Makefile loads =tests/run-coverage-file.el=, which
+initializes packages and configures Undercover:
+
+#+begin_src emacs-lisp
+(undercover "modules/*.el"
+ "gptel-tools/*.el"
+ (:report-format 'simplecov)
+ (:report-file ".coverage/simplecov.json")
+ (:merge-report t)
+ (:send-report nil))
+#+end_src
+
+Undercover is therefore the instrumentation layer: it instruments
+=modules/*.el= and =gptel-tools/*.el=, records Edebug stop-point hits while
+tests execute, and writes the line hit arrays. SimpleCov is the local
+interchange format consumed by the rest of this design. The split-per-test-file
+Makefile strategy depends on =:merge-report t=; Undercover can merge SimpleCov
+reports across separate Emacs processes, while its LCOV writer cannot merge
+reports. This is the concrete reason the artifact is
+=.coverage/simplecov.json= rather than =coverage.lcov=.
+
+The Makefile excludes tests that are incompatible with instrumented source
+loading, such as byte-compilation checks. If =.coverage/simplecov.json= is not
+created, the coverage run is considered failed; downstream report commands
+should not infer partial coverage from a missing artifact.
+
+*** Backend protocol
+
+Each backend is a plist registered into =cj/coverage-backends=:
+
+#+begin_src emacs-lisp
+(:name 'elisp
+ :detect (lambda () ...) ; non-nil if current project matches
+ :run (lambda (cb) ...) ; kick off coverage build; invoke CB with report path
+ :report-path (lambda () ...)) ; where the simplecov JSON lives (for re-reading without running)
+#+end_src
+
+Detection precedence: =.dir-locals.el= override (=cj/coverage-backend= set to a backend name), then project-root fingerprints (=go.mod=, =pyproject.toml=, =package.json=, =.el= files + Makefile, etc.). First =:detect= that matches wins. No silent fallback — if nothing matches, the command errors with guidance.
+
+*** Pure helpers
+
+- =cj/--coverage-parse-simplecov FILE= → hash-table ={file → covered-line-set}=.
+- =cj/--coverage-changed-lines SCOPE BASE= → hash-table ={file → changed-line-set}= by shelling a =git diff --unified=0= for the selected scope and parsing hunk headers.
+- =cj/--coverage-intersect COVERED CHANGED= → per-file records with three buckets: covered, uncovered, not-tracked.
+
+These helpers are pure and covered by focused ERT tests.
+
+** Data Flow
+
+1. User invokes =cj/coverage-report= (bound to =F7=).
+2. Core resolves the backend for the current project.
+3. =completing-read= prompts for scope:
+ - "Working tree — all uncommitted changes"
+ - "Staged — about to commit"
+ - "Branch vs parent" (uses =@{upstream}= unless a caller passes an explicit base to the helper)
+ - "Branch vs main" (explicit)
+ - "Whole project — all executable lines"
+4. If =simplecov.json= is missing, prompt to run coverage. A prefix argument
+ (=C-u F7=) forces a fresh run. Otherwise the existing report is used as-is.
+5. Parse simplecov, compute changed lines or all executable lines, intersect.
+6. Display a report buffer in a mode derived from =compilation-mode=.
+
+** Persistence
+
+- =.coverage/simplecov.json= at the project root, gitignored. Overwritten on each run.
+- No long-term storage. Historical tracking is explicitly out of scope for v1.
+
+** Error Handling
+
+*Pre-flight:*
+- No backend matches → =user-error= with instructions to register a backend or set =.dir-locals.el=.
+- =.dir-locals.el= names an unknown backend → error listing registered backends.
+- Not in a git repository → error; don't swallow git's stderr.
+- Branch comparison on a repo with no common ancestor (orphan branch, shallow
+ clone missing the fork point, or missing upstream) reports the underlying git
+ failure.
+
+*During the coverage run:*
+- Backend =:run= fails (test failure, Make error) → keep the =compile= buffer visible, do *not* proceed to display a report. Partial data is worse than no data.
+- Run completes but no simplecov.json produced → error naming the expected path.
+
+*Post-flight classification:* three buckets, not two.
+- *Covered* — changed line in the simplecov covered-line set.
+- *Uncovered* — changed line in a tracked file but not covered.
+- *Not tracked* — changed file isn't in the simplecov data at all (test files, READMEs, config). Reported separately — don't conflate "coverage didn't look here" with "tests didn't exercise this code."
+
+*Happy-path degenerates:*
+- Zero changed lines in scope → "No changes in this scope; nothing to report."
+- All changed lines covered → "N of N changed lines covered. "
+
+** Keybindings
+
+*Global:*
+- =F7= → =cj/coverage-report= (prompts scope, shows report).
+- =C-u F7= → force re-run regardless of report freshness.
+
+*In the report buffer* (compilation-mode derived, most inherited for free):
+- =RET= → jump to source under point.
+- =n= / =p= → next / previous uncovered line.
+- =q= → bury buffer.
+
+*Globally available via compilation-mode integration:*
+- =M-g n= / =M-g p= → =next-error= / =previous-error= on the last compilation buffer.
+- =C-x `= → visit next uncovered line without leaving the current buffer.
+
+The =F4=–=F7= developer block currently uses =F6= for project-aware test
+dispatch and =F7= for coverage.
+
+** Testing
+
+*Pure helpers, fully tested* (Normal / Boundary / Error for each):
+- =cj/--coverage-parse-simplecov= — handcrafted simplecov JSON in temp files; empty object, all-null coverage arrays, spaces in filenames, multiple test-name keys unioned, malformed JSON.
+- =cj/--coverage-simplecov-executable-lines= — whole-project executable-line set, including zero-hit executable lines.
+- =cj/--coverage-changed-lines= — =cl-letf= over =shell-command-to-string= to return canned =git diff= output; single hunk, new-file hunk, deletion-only hunk, binary marker, no-diff case.
+- =cj/--coverage-intersect= — pure table-in / table-out; covered ⊇ changed, unknown files, nil/empty inputs.
+- =cj/--coverage-format-report= and =cj/--coverage-format-summary= — report text and whole-project per-file summary.
+
+*Backend registry, structurally tested:*
+- =cj/coverage-backend-for-project ROOT= — synthetic temp project roots with marker files; assert correct backend. Registration-order test: two backends match, first-registered wins.
+
+*Not tested:*
+- =cj/coverage-report= interactive command — one smoke test with a prepared simplecov report and a stubbed git-diff. No tests for the prompt UI or the compilation-buffer display.
+- The elisp backend's =:run= function — shells to =make coverage=; integration-test-shaped, low value, slow. Skipped by design.
+
+* Current Limitations
+
+- =make coverage= runs all unit test files except known instrumentation
+ conflicts. It does not try to select only tests related to changed modules.
+- Existing reports are not checked for staleness. Use =C-u F7= or
+ =make coverage= when a fresh report matters.
+- Only the Elisp backend is implemented.
+- There is no CI coverage publishing. The generated
+ =.coverage/simplecov.json= file is local and gitignored.
diff --git a/docs/specs/debug-profiling-spec.org b/docs/specs/debug-profiling-spec.org
new file mode 100644
index 000000000..5961071b8
--- /dev/null
+++ b/docs/specs/debug-profiling-spec.org
@@ -0,0 +1,207 @@
+:PROPERTIES:
+:ID: c713b431-ae14-498d-aba9-b84d52f981b6
+:STATUS: not-started
+:END:
+#+TITLE: Design: debug-profiling.el module
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-04-26
+
+* Status
+
+Draft. Not yet implemented.
+
+* Problem
+
+Profiling helpers in this config exist but are scattered: =profiler-start/stop/report= bindings live in =modules/config-utilities.el=, the =cj/benchmark-this-method= helper sits next to them, =modules/org-agenda-config-debug.el= handles its own thing, =early-init.el= has a commented-out =benchmark-init= block, and =custom/profile-dotemacs.el= drives =make profile=. There's no single place to look when you need to investigate a slow command, and no callable pure helpers for batch / test / external use.
+
+The immediate driver is the queued [#B] task to investigate org-capture target-building (15-20s, 12+ times/day). Future perf work — refactor audits, regressions caught during testing — should reach for the same module.
+
+* Non-Goals
+
+- Startup / load-time measurement (was option B in the brainstorm scope question — ruled out for v1).
+- Hot-path / interactive-loop watchdog with thresholds and alerts (option C — ruled out).
+- Comparative before/after measurement as a first-class feature (option D — ruled out).
+- Memory / GC-pressure tracing as a separate mode (option E — ruled out; GC time still surfaces in =benchmark-run= output).
+- Log accumulation across sessions — measurements are one-shot.
+- Replacing or shadowing the existing =profiler-start/stop/report= keystrokes; those stay as escape hatches.
+
+* Approaches Considered
+
+** Recommended: named-operation surface, no clever stuff
+
+Module exposes ~4 named functions: =cj/profile-next-command=, =cj/profile-cancel=, =cj/time-region-or-sexp=, =cj/profile-report-show=. Each is a small wrapper around the built-in =profiler.el= and =benchmark.el=. No macros, no logs, no auto-discovery.
+
+*Pros:* Easy to read, easy to test, easy to remember; matches the project's split-for-testability convention; zero new ELPA dependencies; byte-compile clean from day one.
+
+*Cons:* Boring is sometimes the wrong answer — if the org-capture investigation reveals a need for "profile this form 1000 times to amortize noise" or comparison-over-time, that's not built in. Followups would land in a v2.
+
+** Rejected: macro-first design
+
+Module's primary surface would be =cj/with-timing= and =cj/with-profile= macros wrapping arbitrary forms. Lets you sprinkle instrumentation directly into source code. Rejected because investigation-by-source-edit mutates code under measurement (can hide GC interactions and inlining), and macros are harder to reason about for someone reading a call site cold. Worth re-considering in v2 if v1 reveals friction.
+
+** Rejected: log-and-grep design
+
+Each measurement appends a structured JSON-lines record to =data/profile.log=; no interactive report, just data. Rejected for v1 — solves "comparison over time" which the agreed scope rules out, and shifts the mental model from "show me the report now" to "the data is in the log somewhere." Could be a v2 add.
+
+** Rejected: treesit-aware instrumentation picker
+
+Walk elisp source via treesit, list candidate functions via =completing-read=, wrap chosen function with timing on the fly. Rejected as a sister implementation of =trace-function= for no clear benefit, with a treesit-elisp-grammar dependency that isn't standard yet.
+
+** Rejected: ELPA-augmented wrapper (benchmark-init, esup, memory-usage)
+
+Pull in established profiling packages and orchestrate them. Rejected because most of those packages target startup or memory specifically — both ruled out in the scope question. Would drift past the agreed MVP.
+
+* Design
+
+** Architecture
+
+New module =modules/debug-profiling.el=. Lexical-binding, byte-compile clean from day one. Conventional =cj/...= public / =cj/--...= private naming.
+
+Top-level shape:
+
+#+begin_example
+modules/debug-profiling.el
+├── (require 'profiler) ; built-in
+├── (require 'benchmark) ; built-in
+├── (defvar cj/--profile-armed nil) ; module-private
+├── (defvar cj/--profile-last-result nil) ; module-private
+│
+├── ;; Pure helpers
+├── (defun cj/profile--run-form (FORM) → profile-data)
+├── (defun cj/profile--arm) ; sets up profiler + hook
+├── (defun cj/profile--disarm) ; tears down + stores result
+├── (defun cj/time--expr (FORM &optional REPS) → (elapsed gc-time gc-count))
+│
+├── ;; Interactive wrappers
+├── (defun cj/profile-next-command ...)
+├── (defun cj/profile-cancel ...)
+├── (defun cj/profile-report-show ...)
+└── (defun cj/time-region-or-sexp ...)
+#+end_example
+
+*** Migration from =modules/config-utilities.el=
+
+Out of =config-utilities.el=:
+- =(require 'profiler)=
+- The three keybindings for =profiler-start= / =profiler-stop= / =profiler-report=.
+- =cj/benchmark-this-method= defun.
+- The =which-key= labels for ="C-c d p"= submenu and ="C-c d b"=.
+
+Stays in =config-utilities.el=:
+- =cj/debug-config-keymap= itself (debug umbrella; profiling is one subset).
+- All non-profiling debug entries (=toggle-debug-on-error=, compile menu, info menu, reload init).
+
+*** Keybindings (all under =C-c d=)
+
+| Key | Command | Status |
+|-----+---------+--------|
+| =p s= | =profiler-start= | Kept (escape hatch) |
+| =p h= | =profiler-stop= | Kept (escape hatch) |
+| =p r= | =profiler-report= | Kept (escape hatch) |
+| =p n= | =cj/profile-next-command= | New (primary) |
+| =p c= | =cj/profile-cancel= | New (abort armed window) |
+| =b= | =cj/time-region-or-sexp= | Renamed from =cj/benchmark-this-method=, same key |
+
+The new module owns the =which-key= label registration for these entries (matches the pattern in =config-utilities.el:20-36= for its own bindings). =config-utilities.el='s registration block drops the profiling labels.
+
+** Data Flow
+
+*** =cj/profile-next-command= (auto-stop wrapper)
+
+1. User invokes — =C-c d p n=.
+2. =cj/profile--arm=:
+ - Checks =cj/--profile-armed=; if =t=, signals =user-error= "Profile already armed."
+ - Calls =profiler-start ='cpu+mem=. If profiler externally active, catches that error and signals =user-error= "Profiler already active — =M-x profiler-stop= first."
+ - Registers a one-shot function on =post-command-hook=. Function removes itself on first call.
+ - Sets =cj/--profile-armed= to =t=, =message='s "Profile armed — run any command to capture."
+3. User performs the slow operation (e.g., =M-x org-capture=).
+4. After that command's command-loop iteration completes, the hook fires =cj/profile--disarm=:
+ - Removes itself from =post-command-hook=.
+ - Calls =profiler-stop= inside =unwind-protect= (always runs even if the operation threw).
+ - Stores the captured profile in =cj/--profile-last-result=.
+ - Clears =cj/--profile-armed=.
+ - =message='s "Profile captured. Opening report..." and calls =profiler-report=.
+
+*** =cj/time-region-or-sexp=
+
+1. User invokes — =C-c d b=.
+2. Wrapper: =(or (use-region-p) (sexp-at-point))= guard. If neither, =user-error= "No region active and no sexp at point."
+3. Reads the region or sexp as a string, calls =read-from-string= to convert to elisp form. Handles parse errors with =user-error=.
+4. Calls =(cj/time--expr FORM 1)= → returns =(elapsed-seconds gc-seconds gc-count)=.
+5. Wrapper formats: ="0.234s (GC: 0.012s, 3 collections)"= and emits via =message=.
+
+*** =cj/profile-cancel=
+
+1. User invokes — =C-c d p c=.
+2. If =cj/--profile-armed= is =nil=, =user-error= "No profile armed."
+3. Otherwise call =cj/profile--disarm= without opening the report.
+
+*** =cj/profile-report-show=
+
+Shows the buffer for =cj/--profile-last-result=. If =nil=, =user-error= "No profile captured yet."
+
+*** Pure helper signatures
+
+Both callable from batch and from tests:
+
+#+begin_src emacs-lisp
+(cj/profile--run-form FORM) ; → opaque profile object
+(cj/time--expr FORM &optional REPS) ; → (elapsed gc-time gc-count)
+#+end_src
+
+** Error Handling
+
+Failure modes and responses:
+
+1. *Armed twice.* =cj/profile--arm= checks =cj/--profile-armed=; signals =user-error= naming the recovery (=cj/profile-cancel=). Recoverable.
+2. *Time with no region or sexp.* =user-error= naming both required states. No state to clean up.
+3. *=read-from-string= on malformed elisp.* =condition-case= around the parse; =user-error= on =end-of-file= / =invalid-read-syntax= naming the source text and parse error.
+4. *Form passed to =cj/profile--run-form= raises.* =unwind-protect= guarantees =profiler-stop= runs. Error propagates to caller — not swallowed. Profile data captured up to the throw is stored.
+5. *=C-g= during the armed window.* The =post-command-hook= still fires for =keyboard-quit=, so auto-stop runs. Profile may be empty/short, but no leaked state.
+6. *Profiler externally active.* =profiler-start= errors. Wrapper catches and signals =user-error= naming the resolution.
+7. *Disarm when not armed.* Silent no-op (idempotent cleanup).
+
+*Invariant:* the profiler never leaks. Every code path that calls =profiler-start= is wrapped in =unwind-protect= with =profiler-stop= in the cleanup branch.
+
+** Testing
+
+Per =.claude/rules/elisp-testing.md=: pure helpers get full Normal/Boundary/Error coverage, interactive wrappers get smoke tests or none.
+
+Test files (one per function, project convention):
+
+| File | Function tested | Test categories |
+|------+-----------------+-----------------|
+| =tests/test-debug-profiling--time-expr.el= | =cj/time--expr= | Normal: 3-tuple of numbers for a simple form. Boundary: empty =(progn)=, REPS=1, REPS=100. Error: form that raises propagates. |
+| =tests/test-debug-profiling--run-form.el= | =cj/profile--run-form= | Normal: returns a profile object; predicate true. Boundary: trivial form. Error: form raises → error propagates AND =profiler-running-p= is =nil= after (invariant test). |
+| =tests/test-debug-profiling--arm.el= | =cj/profile--arm= | Normal: sets =cj/--profile-armed= true, profiler running, hook registered. Boundary: armed-twice → =user-error=. Error: profiler externally active → =user-error=. |
+| =tests/test-debug-profiling--disarm.el= | =cj/profile--disarm= | Normal: clears flag, removes hook, stops profiler, populates =cj/--profile-last-result=. Boundary: disarm when not armed → silent no-op. Error: profiler already stopped → silent OK. |
+
+Skip from unit tests: interactive wrappers (=cj/profile-next-command=, =cj/time-region-or-sexp=, =cj/profile-cancel=, =cj/profile-report-show=). Each gets a single smoke test confirming dispatch to the right helper — no =cl-letf= on =read-from-string= / =use-region-p= / etc.
+
+Coverage target: the four pure helpers should be ≥90% per project rules (utilities + helpers tier).
+
+** Observability
+
+Light by design — the module is observability tooling, not an instrumented system.
+
+*=message= notifications:*
+- On arm: ="Profile armed — run any command to capture."=
+- On disarm: ="Profile captured (N.NN seconds). Opening report..."= (then opens report buffer).
+- On =cj/time-region-or-sexp= completion: ="0.234s (GC: 0.012s, 3 collections)"=.
+
+*=user-error= for invalid states.* Each error names the state and the recovery action. No silent failures.
+
+*No log accumulation, no external instrumentation, no metrics export.* =cj/--profile-last-result= keeps just the most recent capture so =cj/profile-report-show= can re-open without re-running.
+
+*Module-private state visibility:* =cj/--profile-armed= and =cj/--profile-last-result= are module-private (=cj/--= naming). Inspect ad-hoc via =M-: cj/--profile-last-result RET=. Not exposed as commands.
+
+* Open Questions
+
+- [ ] Default REPS value for =cj/time--expr= when called interactively. Brainstorm assumed =1=. Could plausibly default higher (e.g., =3=) to amortize first-call noise. Worth picking after using v1 a few times. Likely an =arch-decide= candidate if it has performance-impact implications.
+- [ ] =profiler-start= argument: ='cpu+mem= captures both CPU and memory; ='cpu= alone is faster and avoids GC interaction in the profile. Default to ='cpu+mem= per the v1 design but reconsider after the org-capture investigation reveals which signal mattered.
+
+* Next Steps
+
+1. Implement under =/start-work= against this design. Branch =feat/debug-profiling=.
+2. Decompose into commits per the design's split-for-testability boundary: tests-first per pure helper, then the interactive wrappers, then the migration of =cj/benchmark-this-method= and the =which-key= label cleanup in =config-utilities.el=.
+3. Use the new module on the queued [#B] org-capture investigation as the first real exercise.
diff --git a/docs/specs/dev-setup-project-spec.org b/docs/specs/dev-setup-project-spec.org
new file mode 100644
index 000000000..5d64f368f
--- /dev/null
+++ b/docs/specs/dev-setup-project-spec.org
@@ -0,0 +1,162 @@
+:PROPERTIES:
+:ID: 596fce5d-1bab-46e7-8567-d4a2e0923091
+:STATUS: not-started
+:END:
+#+TITLE: Design: cj/dev-setup-project
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-04-22
+
+* Status
+
+Draft. Not yet implemented.
+
+* Problem
+
+Adopting the F4 / F6 / F7 dev-block keybindings (compile+run, test, coverage) on a new project means configuring projectile's per-project compile/run/test commands plus the coverage backend. That's a few minutes of ceremony per project, and the polyglot Docker case (backend + frontend in subdirectories) needs per-subproject configuration that projectile's cache doesn't handle cleanly.
+
+=cj/dev-setup-project= is the interactive helper that removes that ceremony. It detects the project shape, proposes the right =.dir-locals.el= content for each subproject, optionally generates a starter Makefile when none exists, and writes everything in one reviewed step.
+
+* Non-Goals
+
+- Running the detected commands. The helper only writes configuration; you invoke F4 / F6 / F7 afterwards.
+- Managing Dockerfile changes, compose file edits, or container orchestration. Those stay hand-owned.
+- Replacing projectile's cache for simple single-language projects. If you're fine with projectile's prompt-and-cache, don't run the helper.
+- Supporting every possible project shape. The helper targets the shapes the user actually uses: pure Elisp, pure Go, pure Python, pure Node/TS, Docker Compose polyglot.
+
+* Approaches Considered
+
+** Recommended: detect + review buffer + user commits
+
+Interactive command opens a review buffer pre-populated with proposals. User edits inline. On =C-c C-c=, helper writes the files.
+
+Detection is three-tier: existing Makefile, existing package.json / pyproject.toml scripts, or fall back to generating a starter Makefile. Re-runs use the same buffer with status banners (UNCHANGED, WILL UPDATE, WILL CREATE) so nothing changes silently.
+
+*Pros:* Zero silent surprises. User sees exactly what's going to change. Reuses the same UX for initial setup and re-runs.
+
+*Cons:* More code than a "just write the files" approach. Review buffer mode is a small but non-trivial piece of UX.
+
+** Rejected: silent auto-detect and commit
+
+Helper inspects project, writes =.dir-locals.el= immediately with best-guess conventions, prints a summary. Zero friction on the easy cases. Wrong results on edge cases go unnoticed until you hit F4/F6 and they misfire. Not worth the friction savings.
+
+** Rejected: wizard (prompt each question in sequence)
+
+Helper asks "Test command: [default: make test] > " and so on. Explicit and safe, but slow and the series of minibuffer prompts is a worse fit than a single editable review buffer.
+
+** Rejected: hybrid (silent for obvious cases, wizard for polyglot)
+
+Two code paths to maintain. The review-buffer approach is already fast for obvious cases (one =C-c C-c= to accept the proposal) and correct for polyglot cases. No need for a second path.
+
+* Design
+
+** Detection
+
+Three tiers, checked in order.
+
+*** Tier 1: existing Makefile
+
+Parse Makefile for =.PHONY:= declarations and bare =^target:= lines. Collect the target names.
+
+Best-guess role mapping:
+- *compile* role: prefer =build=, =compile=, =install=
+- *run* role: prefer =run=, =start=, =dev=, =serve=
+- *test* role: prefer =test=, =tests=, =check=
+
+If multiple targets match (e.g., both =test= and =check=), pick the first match and list the others in the review buffer as "other available targets."
+
+*** Tier 2: existing package.json scripts or pyproject.toml sections
+
+- =package.json= with a =scripts= block: parse the block, same best-guess mapping (=dev= → run, =build= → compile, =test= → test). Command prefix is =npm run=.
+- =pyproject.toml= with =[tool.pytest]= or =[project.scripts]=: for v1, skip this — fall back to =pytest= as the test command if =pytest= is on PATH. More sophisticated parsing can come later.
+
+*** Tier 3: no build file found
+
+Propose a starter Makefile in the review buffer. User edits or declines.
+
+The starter Makefile adapts to the detected project type:
+
+- Elisp: =make compile=, =make test= wrapping =emacs --batch= invocations.
+- Go: =go build=, =go run=, =go test ./...=.
+- Python (non-Docker): =pip install -r requirements.txt=, =python -m <module>=, =pytest=.
+- Node/TS (non-Docker): =npm install=, =npm run dev=, =npm test=.
+- Docker Compose polyglot: =docker compose build=, calls to user-named external run script (prompted), =docker compose exec <service> <runner>= for tests per service.
+
+** Review Buffer
+
+Custom major mode derived from =emacs-lisp-mode= with two local bindings:
+
+- =C-c C-c= — parse the buffer, validate all blocks, write files, show summary.
+- =C-c C-k= — abort, write nothing.
+
+Block syntax: =;; ==== <path> ====[ <status>]== banner lines delimit each file's proposed content. Status banner is one of:
+
+- (unset, initial setup) — file will be created
+- =[UNCHANGED]= — current file matches proposal; skipped unless user edits
+- =[WILL UPDATE]= — current file differs; shown with both current and proposed for the user to pick
+- =[WILL CREATE]= — file doesn't exist yet; will be created
+
+=;; ==== .gitignore (append if missing) ===== is a special banner — lines under it are appended to =.gitignore= if not already present.
+
+=;; ==== Makefile ====[...]= is a special banner — only honored if no Makefile exists at the target path. On re-run with an existing Makefile, this banner is suppressed entirely.
+
+** Escape Hatch
+
+A =.dir-locals.el= containing =;;; cj/dev-setup-project: ignore= as the first line is skipped on re-run. Lets the user diverge intentionally without every re-run reverting.
+
+** Write Step
+
+On =C-c C-c=:
+
+1. Parse all blocks. Validate each is well-formed elisp (or well-formed Makefile / gitignore entries).
+2. If any block is malformed, show an error in the review buffer and do not write.
+3. For each WILL UPDATE / WILL CREATE block: write the file.
+4. For the gitignore block: append each line only if not present (idempotent).
+5. Clear projectile's per-project command cache for this project (so new commands take effect on next F4/F6).
+6. Print a summary: ="Wrote backend/.dir-locals.el, frontend/.dir-locals.el, appended 2 lines to .gitignore."=
+
+** Coverage Backend Forward References
+
+The helper writes =(cj/coverage-backend . python)=, =(cj/coverage-backend . typescript)=, etc. even when those backends don't exist yet (MVP coverage ships Elisp only). The binding silently does nothing until the backend lands; after that, it activates automatically. Simpler than leaving empty and coming back.
+
+** Example Flows
+
+*** Fresh setup on orchestration_dashboard_mvp (Tier 3, no Makefile)
+
+Review buffer proposes a Makefile (calling the user's existing =reset-dashboard.sh= as the =run= target) plus backend/ and frontend/ =.dir-locals.el= files plus gitignore updates. User edits the Makefile's =run= target to match their actual script path. =C-c C-c=. Four files written.
+
+*** Fresh setup on .emacs.d (Tier 1, rich Makefile)
+
+Review buffer shows target-to-role mapping derived from the existing 14-target Makefile (=make compile= → compile role, =make test= → test role; =run= role left nil since this is a config project). Single file written: =.dir-locals.el= at project root.
+
+*** Re-run after adding a new compose service
+
+The helper detects a new =worker= service in docker-compose.yml with a =./worker/= build context. Existing backend/ and frontend/ files show =[UNCHANGED]=. New =worker/.dir-locals.el= block shows =[WILL CREATE]=. =C-c C-c=. One file written.
+
+*** Re-run after renaming a Makefile target
+
+Makefile's =test-frontend= was renamed to =test-frontend-unit=. The helper detects the mismatch and shows frontend/.dir-locals.el as =[WILL UPDATE]= with current and proposed visible. User either accepts (the test command updates) or edits the buffer to keep =test-frontend=. Nothing silent.
+
+* Testing
+
+Pure helpers, fully tested per the project's Normal / Boundary / Error discipline:
+
+- =cj/--dev-setup-parse-makefile-targets FILE= — handcrafted Makefiles. Normal: two-target file with .PHONY. Boundary: tabs vs spaces, continuation lines, pattern-rule targets (skip them). Error: file missing, non-Makefile content.
+- =cj/--dev-setup-parse-package-json-scripts FILE= — synthetic package.json fixtures. Normal: valid scripts block. Boundary: no scripts block, empty scripts. Error: malformed JSON.
+- =cj/--dev-setup-detect-project-shape ROOT= — temp directories with combinations of marker files. Assert returned shape plist. Normal: each single-language case. Boundary: docker-compose polyglot with one subproject, with two subprojects, with a service that uses an external image (no subproject). Error: empty directory returns 'unknown.
+- =cj/--dev-setup-map-targets-to-roles TARGETS= — input list of target names, output role mapping. Normal: well-named project (build/run/test). Boundary: unusual names (start instead of run; check instead of test). Error: empty input returns empty mapping.
+- =cj/--dev-setup-review-buffer-parse CONTENTS= — the buffer-format parser. Normal: well-formed buffer with multiple blocks. Boundary: single block, block with empty body. Error: missing banner, malformed elisp inside a dir-locals block.
+
+Not tested (by design):
+- The interactive command =cj/dev-setup-project= itself — one smoke test that runs against a prepared temp project and asserts the expected files exist after =C-c C-c=.
+- The review-buffer major mode's keybindings.
+
+* Open Questions
+
+- [ ] Whether to also detect Cargo.toml (Rust), pom.xml (Java/Maven), etc. v1 targets Elisp, Go, Python, TS/JS. Rust/Java defer.
+- [ ] Whether =cj/dev-setup-project= should also offer to add a =make coverage= target when generating a Makefile. Probably yes — it's the natural partner to the coverage work.
+- [ ] Whether to support a project-wide config override file (=.cj-dev-setup.el= at project root) that pins choices regardless of what detection finds. Defer unless the detection-only path proves annoying.
+
+* Next Steps
+
+1. Implement after the F-key rework ticket ships.
+2. Open questions above → resolve inline or via =arch-decide= if they turn out to be load-bearing.
diff --git a/docs/specs/dupre-clear-theme-spec.org b/docs/specs/dupre-clear-theme-spec.org
new file mode 100644
index 000000000..9594eb3d9
--- /dev/null
+++ b/docs/specs/dupre-clear-theme-spec.org
@@ -0,0 +1,93 @@
+:PROPERTIES:
+:ID: 20df7f50-4759-47ba-9782-8dd25a2e173e
+:STATUS: not-started
+:END:
+#+TITLE: dupre-clear — a contrast-first AAA sibling theme
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-07
+
+* Status
+
+Spec / not started. Working name *dupre-clear* (final name TBD — see Open Questions). Sibling to the in-progress *dupre revision* (see "Relationship" below). Linked from a task in =todo.org= under Emacs Open Work.
+
+* One-line concept
+
+Take dupre's color identity and rebuild it the way Prot built modus: *contrast-first*. Where the dupre revision optimizes for mood and depth (lands at WCAG AA), dupre-clear optimizes for legibility (targets WCAG AAA, ~7:1 on the ground) — the same soul, dialed for maximum clarity.
+
+* Motivation
+
+This came out of a long 2026-06-07 design session that produced the *dupre revision* (an elegant, AA-level theme — see the dupre-redesign entry in =.ai/session-context.org= for the full palette + mapping). Near the end we analyzed how Prot actually generated the modus palette, and the finding reframes the whole approach:
+
+- We built our palette *aesthetics-first*: pick a beautiful/deep/dusty color, accept whatever contrast falls out. Result: a rich palette that mostly lands at AA (4.5–6.5:1), with a couple of colors needing a nudge to clear AA at all.
+- Prot built modus *contrast-first*: the ~7:1 AAA floor is the non-negotiable starting constraint; he then hand-picks the nicest color that clears it for each role.
+
+dupre-clear is the "what if we applied Prot's discipline to dupre's colors" theme. It's not a fix to the dupre revision — both are valid, they're just tuned for different priorities. Some people (and some lighting / monitor / eyesight conditions) want the maximally-legible version; this is that version, without abandoning dupre's character.
+
+* The Prot methodology (evidence, so we don't re-derive it)
+
+Pulled from =/usr/share/emacs/30.2/etc/themes/modus-vivendi-theme.el= on 2026-06-07. modus has 6 hue families (red, green, yellow, blue, magenta, cyan), each with base + =-warmer= / =-cooler= / =-faint= / =-intense=, plus bg/fg roles and a large semantic-mapping layer (~128 named colors, ~177 mappings).
+
+Key finding: *the variants are NOT algorithmically derived from the base.* If they were (e.g. warmer = hue rotate by N, faint = saturation × 0.5), the HSL numbers inside each family would move regularly. They don't:
+
+- "faint" is not a consistent saturation cut: =red-faint= is S100 (fully saturated, just lighter), =green-faint= is S38 (heavily desaturated), yellow/blue/magenta/cyan faint land at S48/74/47/53.
+- "cooler" is not a consistent lightness move: =red-cooler= is lighter (L67→75), =green-cooler= is darker (L50→38).
+
+What IS systematic is *contrast*. Every modus-vivendi color clears roughly 7:1 on the =#000000= background (red family 7.0–9.9, green 8.5–11.9, cyan 11–14, the lowest being blue-intense at 6.5). So the invariant is the AAA contrast floor; the colors are individually hand-curated to (a) read as their named relationship and (b) clear the floor. The variant names are *descriptions of perceptual roles*, not outputs of a formula.
+
+Implication for dupre-clear: don't write an HSL-transform generator. Set the 7:1 floor, then hand-pick (or constraint-solve) the richest dupre-flavored color that clears it for each role.
+
+* Design principles for dupre-clear
+
+1. *Keep dupre's identity*: the warm near-black ground =#0d0b0a=, the warm-grey/metallic neutrals, and the hue families (the dupre blue, emerald, gold, terracotta, regal violet, mint). The HUES stay recognizably dupre; the brightness/saturation change to meet contrast.
+2. *Contrast-first*: target ~7:1 AAA on the ground for all foreground syntax text. Comments may sit at AA-large (de-emphasis is intentional). Fills (navy, regal purple) are exempt — they carry light text, so their own ground-contrast is irrelevant.
+3. *Accept the cost*: the deep/dusty choices from the dupre revision will have to brighten to reach AAA. dupre-clear is allowed to be more vivid than the revision — that's the point. Don't try to keep both depth and AAA on the same black; they pull opposite ways (proven repeatedly in the session).
+4. *Same mapping, brighter values*: reuse the dupre revision's role assignments (below); only the color values move.
+5. *Same modus two-layer structure*: a raw palette + a semantic-mapping layer, so it can retarget cleanly and read like a real systematized theme.
+
+* Starting point: the dupre revision palette + mapping (the AA version to brighten)
+
+These are the dupre-revision (AA) values as of 2026-06-07. dupre-clear keeps the roles, brightens the values to AAA. Ground =#0d0b0a=, default/fg silver =#d8d8d8=.
+
+| role | dupre revision (AA) | contrast | dupre-clear target |
+|------+---------------------+----------+--------------------|
+| keyword (BOLD) | blue #67809c | 4.8 | a dupre-blue bright enough to clear ~7:1 as bold text (note: a deep blue can't be AAA on near-black — may need to lighten meaningfully, or keep bold + accept ~AA for blue as the one exception, OR lift the ground; this is the hardest slot) |
+| function | gold metallic #e8bd30 | 11.0 | already AAA — keep |
+| type | regal violet #9b5fd0 | 4.6 | brighten toward ~7:1 (the L57→L66 sweep showed #ab79d8 ≈ 6.0; go a touch brighter for 7) |
+| string | emerald dusty #2ba178 | 6.1 | brighten/saturate to ~7:1 (the vivid #1bb17d was 7.1) |
+| constant/number | terracotta #cb6b4d | 5.4 | brighten toward 7:1 (toward the lighter terracotta #d19475 ≈ 7.7, or re-pick) |
+| default / vars / punct | silver #d8d8d8 | 13.8 | already AAA — keep |
+| comment | warm-dim #6f655a | 3.4 | intentionally recessive; AA-large is fine even in the clear theme |
+| docstring | muted emerald #5d9b86 | 6.1 | brighten to ~7 |
+| spare | mint #8dc4af | 10.0 | already AAA |
+
+Structural / fills (unchanged role): metallic greyscale ramp (gunmetal #2f343a → pewter #5e6770 → steel #838d97 → silver), navy fill #264364, regal-purple fill #562d76. Silver text on navy/regal both clear ~7:1.
+
+The hardest slot is *blue keywords*: a deep dupre blue (#67809c) is intrinsically sub-AAA on near-black (depth and AAA are mutually exclusive there — proven in-session). Options to decide at build time: (a) brighten the blue toward a lighter steel (loses depth), (b) keep blue bold at AA as the single deliberate exception (modus-vivendi itself has blue-intense at 6.5), or (c) lift the ground slightly so a deeper blue clears AAA (changes dupre's signature warm near-black). Worth a focused decision.
+
+* Build approach
+
+1. Decide whether dupre-clear is its own =themes/dupre-clear-*.el= (palette + faces + theme) or shares structure with the dupre revision. Likely its own files: a =dupre-clear-palette.el= + =dupre-clear-faces.el= + =dupre-clear-theme.el=, mirroring the dupre file layout.
+2. Pick each color contrast-first per the table above; verify every foreground color clears the AAA floor with the WCAG helper.
+3. Wire the same semantic mapping (keyword=blue bold, function=gold, type=violet, string=emerald, const=terracotta, comment=warm-grey, default=silver, structural=metallic/navy/regal).
+4. TDD via a =tests/test-dupre-clear-theme.el=, including a WCAG-contrast assertion that every syntax face clears 7:1 (the inverse of the AA test the dupre revision gets — here it's a hard AAA gate). Reuse the contrast helper pattern from =tests/test-dupre-theme.el= (=dupre-test--contrast= etc.).
+5. Live-reload + screenshot to verify per =emacs.md=.
+
+* Relationship to the dupre revision
+
+- *dupre revision* (in progress, 2026-06-07): the elegant-AA reinterpretation of the current dupre theme — mood/depth first. This is the one being built first. Full design in =.ai/session-context.org= (the dupre-redesign entry).
+- *dupre-clear* (this spec): the contrast-first AAA sibling — legibility first, same hues brightened.
+- They share the hue identity and the role mapping; they differ in brightness/saturation and in the contrast target (AA vs AAA). Build the revision first; dupre-clear reuses its hue choices as the starting point and brightens them.
+
+* Tooling + references (so this is resumable cold)
+
+- The session's exploration tooling was a set of throwaway =/tmp/gen-*.py= scripts that render palette + 4-language code samples to HTML and open them in a browser; they include WCAG-contrast and CIEDE2000 (perceptual distance) helpers. Those /tmp files won't survive a reboot — re-derive the helpers (WCAG: relative luminance with the sRGB linearization, contrast = (L1+0.05)/(L2+0.05); CIEDE2000 for separation). The math is also embedded in =tests/test-dupre-theme.el= (the WCAG half).
+- modus reference palette: =/usr/share/emacs/30.2/etc/themes/modus-vivendi-theme.el= (and the operandi/tinted variants alongside it).
+- dupre lineage: dupre ← distinguished (emacs, Kim Silkebaekken) ← vim-distinguished. The dupre palette lives in =themes/dupre-palette.el= + =themes/dupre-faces.el=; swatch PNG at =themes/dupre-palette.png=.
+- The key perceptual lessons from the session (also in the anchor): thin colored text desaturates (muted hues grey out as glyphs — bold helps); a near-black ground forces depth-vs-AAA as a hard tradeoff; Hyprland inactive-window dimming silently shifts colors (disable with =hyprctl keyword decoration:dim_inactive false= during color work).
+
+* Open questions
+
+1. *Name.* "dupre-clear" is a working placeholder; Craig wants a different final name. Candidates to brainstorm: something in the distinguished/dupre lineage that signals "the legible/clear one." Decide at build start.
+2. *Blue keywords at AAA.* The depth-vs-AAA conflict on the dupre blue (see the build-approach note) — pick (a) brighten, (b) keep AA as the deliberate exception, or (c) lift the ground.
+3. *File sharing vs separate.* Whether dupre-clear shares any palette/faces machinery with the dupre revision or stands fully alone.
+4. *Light variant?* Modus ships both vivendi (dark) and operandi (light). Out of scope for v1, but worth noting whether a dupre-clear-light is ever wanted.
diff --git a/docs/specs/face-font-diagnostic-popup-spec.org b/docs/specs/face-font-diagnostic-popup-spec.org
new file mode 100644
index 000000000..bbd67e92d
--- /dev/null
+++ b/docs/specs/face-font-diagnostic-popup-spec.org
@@ -0,0 +1,197 @@
+:PROPERTIES:
+:ID: 98f065cf-8bd5-46a0-ac24-da94d66855ad
+:STATUS: not-started
+:END:
+#+TITLE: Face and Font Diagnostic Popup — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-14
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+
+| Status | not-started |
+|----------+---------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+---------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: Face and font diagnostic popup at point]] |
+
+* Summary
+
+A read-only command that, for the character at point in an ordinary buffer, pops up everything that determines how that character is painted: the full face stack, the effective merged attributes, the real font versus the declared family, and where each attribute came from (theme, config, or inheritance). It exists to answer one question fast — "why does this text look wrong under the theme, and is the fault the theme, my config, or a fallback?"
+
+* Problem / Context
+
+Theme work in this config keeps hitting the same wall: a glyph renders in the wrong color and there's no quick way to see why. The cursor showed gold in auto-dimmed buffers; elfeed rendered all-white ignoring its theme assignments. Each of those is a different layer failing — a face remap, an overlay, an unspecified attribute falling through to the default — and the built-in tools don't separate those layers or trace provenance.
+
+What paints a character is a merge of several sources resolved by the redisplay engine: the default face, then text-property faces (=face= / =font-lock-face=), then overlay faces stacked by priority, all rewritten by any =face-remapping-alist= entries, and finally a font chosen by the fontset that can differ from the face's declared =:family=. To debug a theme issue you need to see each layer, the merged result, and — for each face — whether its current attributes came from the active theme, from config, or from an =:inherit= chain that bottoms out at the default.
+
+=describe-char= and =C-u C-x ​== show the character, its faces as links, and the font, but they don't separate the stack by source, don't surface active remaps, and don't trace attribute provenance. The gap is exactly the part that distinguishes a theme bug from a config bug.
+
+* Goals and Non-Goals
+
+** Goals
+- For the character at point, show the face stack separated by source (text-property, overlay-by-priority, active remaps, default).
+- Show the effective merged attributes — the value that wins for each attribute.
+- Show the real font (=font-at=) next to the face's declared =:family=, to expose fontset substitution.
+- For each face, trace provenance: which theme(s) and/or config set each attribute, the =:inherit= chain, and the unspecified→fallback resolution.
+- Present it in a read-only, navigable help-style buffer obeying the project's unified popup placement and dismissal rules.
+- Degrade gracefully in out-of-scope buffers: show what can be read plus a banner naming the foreign color source — never a bare refusal.
+
+** Non-Goals
+- No editing of faces, themes, or attributes. This is a diagnostic, not an editor; theme-studio owns editing.
+- No reimplementation of =describe-char='s general character report (display tables, composition, char properties beyond faces).
+- No coverage of color sources outside the theme/face system as first-class (terminal ANSI palettes, document HTML/CSS, image buffers) — surfaced, not analyzed.
+- No persistence, history, or export of diagnostic output.
+
+** Scope tiers
+- v1: char-at-point diagnosis with an optional region-scan mode; the five info groups below; the help-style popup; graceful out-of-scope handling.
+- Out of scope: terminal-ANSI buffers, image/PDF buffers, and shr/document-rendered buffers as analyzed targets (they get the banner + best-effort dump).
+- vNext: interactivity — "send this face to theme-studio", jump-to-theme-spec actions, and any write path. Logged to todo.org.
+
+* Design
+
+The command — call it =cj/describe-face-at-point= (final name an open detail) — reads the character at point and builds a report buffer in five groups. It never mutates buffer or frame state.
+
+** For the user (what the popup shows)
+
+1. *Character context.* The character, its codepoint and Unicode name, and its script. Script is what explains fontset routing, so it earns its place even though it's one line.
+
+2. *Face stack, by source.* The layers that contribute, in merge order, each labeled by where it comes from:
+ - text-property faces: the =face= and =font-lock-face= properties at point, in list order, anonymous specs shown inline;
+ - overlay faces: every overlay covering point that carries a =face=, sorted by overlay priority, with a best-effort owner label;
+ - active remaps: the =face-remapping-alist= entries that apply to faces in the stack (this is the auto-dim layer);
+ - the default face underneath.
+ Source separation is the diagnostic — "is this from a text property, an overlay, or a remap?" is half the answer.
+
+3. *Effective merged attributes.* The winning value per attribute (family, height, weight, slant, foreground, background, underline, overline, strike-through, box, inverse-video). This is what actually paints.
+
+4. *Real font vs declared family.* The font =font-at= reports as actually used, next to the merged =:family=. A mismatch means the fontset substituted (emoji, CJK, a missing glyph) — a common "why is this one character different" cause.
+
+5. *Per-face provenance.* For each named face in the stack: which theme(s) set its attributes (=theme-face= property), whether config overrode it (=saved-face= / =customized-face= / a runtime =set-face-attribute=), the =:inherit= chain, and for each unspecified attribute the resolution trace — "=:foreground= unspecified → not set by any theme → no inherit → default fg." That last trace is the direct read on the elfeed-white class of bug.
+
+The report is a read-only buffer in a dedicated mode, with named faces rendered as buttons that re-run the command's per-face section or call =describe-face= (navigation only — no edits in v1).
+
+** For the implementer (how it's built)
+
+A pure core plus a thin interactive wrapper, per the project's interactive/internal split:
+
+- =cj/--face-diagnosis-at (pos &optional buffer)= → a plist describing the five groups. No prompts, no display. This is the testable unit.
+- =cj/describe-face-at-point= (interactive) → calls the core at point, renders the plist into the help buffer, places the window per the unified popup rules.
+- Region mode → maps the core over the distinct face-runs in the active region and concatenates.
+
+Data sources, by group:
+- Stack: =get-text-property= for =face= / =font-lock-face=; =overlays-at= filtered to those with a =face=, sorted by =overlay-get … 'priority=; =face-remapping-alist= (buffer-local) intersected with the stack; =get-char-property= as a cross-check on the merged text-prop+overlay face.
+- Merged attributes: see the open decision below — Emacs exposes no single "final merged plist" call, so the core folds the ordered stack itself.
+- Real font: =font-at=, then =query-font= / =font-info= for its family and name; nil under =--batch=, handled as "unavailable".
+- Provenance: =(get FACE 'theme-face)= for theme spec history, =saved-face= / =customized-face= / =face--attribute-from-frame= comparisons for config overrides, and =face-attribute= with the inherit-following argument to produce the resolution trace.
+
+Buffer classification (group 0, decides scope handling): a predicate inspects =major-mode= derivation and known markers to bucket the buffer as theme-faced (analyze fully), terminal-ANSI, document-shr, or image/no-text. Out-of-scope buckets still render groups 1–2 best-effort and prepend a banner naming the color source.
+
+* Alternatives Considered
+
+** Presentation: childframe / posframe popup
+- Good, because it floats near point and looks modern.
+- Bad, because the report is tall and structured; a childframe is cramped, doesn't scroll naturally, and fights the existing unified-popup policy.
+- Neutral, because a posframe could wrap the same render function later if wanted.
+
+** Presentation: which-key-style transient strip
+- Good, because it's lightweight.
+- Bad, because it can't hold five groups of structured, navigable, copyable text. Wrong tool for a report.
+
+** Reuse: extend describe-char instead of a new command
+- Good, because describe-char already resolves faces and the font and renders links.
+- Bad, because its output is fixed and character-report-shaped; the value here is source-separation and provenance, which would mean rewriting most of its body anyway. Better to study =descr-text.el= for the font/face resolution mechanics and build a focused command than to graft onto a general one.
+- Neutral, because we still reuse the same primitives it uses (=font-at=, =get-char-property=).
+
+** Scope: analyze every buffer uniformly
+- Good, because no classifier to write.
+- Bad, because in a terminal or an shr buffer the provenance trace is misleading — the color isn't from the theme, so "theme didn't set it" reads as a theme bug when it isn't. The banner exists precisely to stop that false read.
+
+* Decisions [6/7]
+** DONE Granularity: char-at-point with optional region scan
+- Context: precise diagnosis wants one character; occasionally you want a whole region surveyed.
+- Decision: We will default to the character at point and offer a region-scan mode over the distinct face-runs when a region is active.
+- Consequences: easier — the common case is one precise report; harder — region mode must dedupe face-runs and concatenate without flooding the buffer.
+
+** DONE Provenance is core v1
+- Context: provenance (theme vs config vs inherit, unspecified→fallback) is the whole reason to build this over describe-char.
+- Decision: We will treat the per-face provenance trace as required v1 content, not a follow-up.
+- Consequences: easier — the tool actually answers theme-vs-config; harder — provenance extraction is the most intricate part and carries Emacs-version risk on the =theme-face= / =saved-face= internals.
+
+** DONE Include the real-font (fontset) layer
+- Context: a face's =:family= can differ from the font actually chosen for a glyph.
+- Decision: We will show =font-at='s real font next to the declared family.
+- Consequences: easier — catches substitution bugs; harder — =font-at= is nil in batch, so tests must tolerate "unavailable".
+
+** DONE Presentation: read-only help-style buffer under the unified popup rules
+- Context: the report is tall and structured and benefits from scrolling, copy, and face links.
+- Decision: We will render into a dedicated read-only buffer and place/dismiss it via the project's unified popup placement and dismissal rules.
+- Consequences: easier — idiomatic, navigable, consistent with other popups; harder — depends on the unified-popup policy, whose placement thresholds are still being settled in its own task.
+
+** DONE Interactivity is vNext
+- Context: a "send face to theme-studio" bridge is attractive but is editing-adjacent.
+- Decision: We will ship v1 read-only; the theme-studio bridge and any write path are vNext.
+- Consequences: easier — v1 stays a safe pure diagnostic; harder — users must round-trip through theme-studio by hand until vNext.
+
+** DONE Out-of-scope buffers: classify and show everything, with a banner
+- Context: a hard refuse in a terminal/shr/image buffer is unhelpful and hides information.
+- Decision: We will classify the buffer, render what we can, and prepend a banner naming the foreign color source instead of refusing.
+- Consequences: easier — maximal information always, and the boundary teaches itself; harder — the classifier must recognize the buffer buckets reliably enough that the banner isn't wrong.
+
+** TODO Effective-attribute computation approach
+- Owner / by-when: Claude / before Phase 2 implementation.
+- Context: Emacs exposes no public call returning the final merged attribute plist for a position (text props + overlays + remaps as the C redisplay merges them). The tool has to produce the "what actually paints" values itself.
+- Decision: We will (proposed) fold the ordered stack manually with =face-attribute=, treating overlays-over-text-props-over-default and applying remaps, and label the merged result as "computed" — accepting that exotic edge cases (relative heights, deep =:inherit= ordering) may diverge slightly from the engine. Alternative under consideration: lift the resolution mechanics from =descr-text.el= / =face-at-point= rather than hand-rolling.
+- Consequences: easier — a single explicit merge we can unit-test; harder — fidelity to the real engine isn't guaranteed for corner cases, so the spec stays "Ready with caveats" until the approach is pinned.
+
+*** Discussion
+- Open until the implementer compares a hand-folded merge against =describe-char='s font/face resolution on a few fixtures (auto-dim default remap, an overlay-with-priority, an unspecified-inherit face) and confirms they agree or documents where they don't.
+
+* Implementation phases
+
+** Phase 1 — Core read model + buffer classifier
+=cj/--face-diagnosis-at= returns the plist for groups 0–2 (classification, character context, face stack by source). Pure, no display. Unit-tested against temp-buffer fixtures with planted text properties, overlays, and remaps. Tree stays green.
+
+** Phase 2 — Merged attributes + real font
+Extend the core with group 3 (effective merged attributes, per the resolved computation decision) and group 4 (=font-at= vs declared family, "unavailable" under batch). Unit-tested on the merge fixtures.
+
+** Phase 3 — Provenance trace
+Add group 5: theme/config/inherit provenance and the unspecified→fallback resolution per face. Tested with fixtures that set a face via a loaded theme, via =set-face-attribute=, and leave one attribute unspecified.
+
+** Phase 4 — Render + popup wiring
+The interactive =cj/describe-face-at-point=, the read-only mode with face buttons, region-scan mode, and placement/dismissal via the unified popup rules. Smoke-tested live; the render function tested on a captured plist.
+
+* Acceptance criteria
+- [ ] On a normal prog/text buffer, the popup shows all five groups for the character at point.
+- [ ] An overlay face (e.g. region) at point appears in the stack, labeled as an overlay, above the text-property faces.
+- [ ] An active =face-remapping-alist= remap (e.g. under auto-dim) appears as the remap layer and is reflected in the merged result.
+- [ ] A face with an unspecified =:foreground= shows the resolution trace down to its actual fallback.
+- [ ] A glyph using a substituted font (e.g. an emoji) shows a real-font ≠ declared-family mismatch.
+- [ ] In a terminal/shr/image buffer, the popup shows a banner naming the color source and still renders what it can.
+- [ ] The core (=cj/--face-diagnosis-at=) returns its plist with no prompts and no display side effects, and passes under =make test= (=--batch=).
+
+* Readiness dimensions
+- Data model & ownership: all data is read live from buffer/overlay/face/font state; nothing user-authored, generated, or persisted. The report plist is ephemeral.
+- Errors, empty states & failure: no character at point (empty buffer / eob) → a clear "nothing at point" message; =font-at= nil under batch → "font: unavailable (batch)"; out-of-scope buffer → banner, not error. No silent data loss (read-only tool).
+- Security & privacy: N/A — reads visible buffer text and face metadata; logs nothing; no credentials.
+- Observability: the tool *is* the observability surface. Its own failures surface as in-buffer messages naming the missing piece (e.g. "font backend unavailable").
+- Performance & scale: single character is trivial; region mode is bounded by distinct face-runs in the region — cap or warn past a threshold so a whole-buffer region doesn't generate a huge report. No live/remote dependency.
+- Reuse & lost opportunities: reuses =font-at=, =get-char-property=, =face-attribute=, =theme-face=/=saved-face= internals, and the project's unified-popup policy and interactive/internal split. Studies =descr-text.el= rather than forking it.
+- Architecture fit & weak points: integration points are the unified-popup placement policy (in flux) and the face/theme internals (=theme-face=, =saved-face=) which are version-sensitive — isolate them behind small accessors so an Emacs-version change touches one place.
+- Config surface: the region-run cap is the one likely knob, with a safe default. Possibly a toggle for whether out-of-scope buffers render best-effort or just the banner.
+- Documentation plan: a docstring on the command, the keybinding noted in the keybinding map, and a CLAUDE.md/notes pointer only if a non-obvious gotcha surfaces. No user manual needed.
+- Dev tooling: existing =make test= / byte-compile / live-reload loop; no new targets.
+- Rollout, compatibility & rollback: additive new command + one keybinding; nothing persisted or migrated; rollback is removing the module. No compatibility surface.
+- External APIs & deps: N/A — pure Emacs primitives, no external API or package dependency.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Merge fidelity* (the open decision): a hand-folded attribute merge may diverge from the redisplay engine on exotic cases. Dodge: validate against =describe-char= on a handful of fixtures; label the result "computed"; don't claim pixel-exactness.
+- *Provenance internals*: =theme-face= / =saved-face= are not a stable public contract. Dodge: isolate behind accessors; tolerate missing properties as "unknown source" rather than erroring.
+- *Unified-popup dependency*: that policy's placement thresholds aren't settled. Dodge: code to the policy's interface, accept whatever defaults it lands on; don't invent a parallel placement scheme here.
+- *Overlay owner labeling*: overlays don't record their creator. Dodge: best-effort label from known marker properties; fall back to "(overlay)" without guessing.
+
+* Review and iteration history
+** 2026-06-14 Sun @ 22:30:00 -0500 — Claude (for Craig) — author
+- What: initial draft.
+- Why: theme debugging keeps hitting layered face/font issues with no tool that separates the layers or traces provenance; agreed to spec before building.
+- Artifacts: [[file:../../todo.org][todo.org: Face and font diagnostic popup at point]]; motivating bugs — gold-text-in-auto-dim, elfeed-ignores-theme.
diff --git a/docs/specs/flycheck-modeline-customization-spec-implemented.org b/docs/specs/flycheck-modeline-customization-spec-implemented.org
new file mode 100644
index 000000000..59567be60
--- /dev/null
+++ b/docs/specs/flycheck-modeline-customization-spec-implemented.org
@@ -0,0 +1,319 @@
+:PROPERTIES:
+:ID: 76979608-956e-474f-90a8-8d0c958101a0
+:STATUS: implemented
+:END:
+#+TITLE: Design: Flycheck modeline customization
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-15
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft. Supersedes the earlier =flycheck-modeline-customization-spec.org=
+draft in =.ai/= (2025-11-14), which used stale line numbers and conflated
+Option 3's risky-local-variable requirement with Option 4.
+
+* Problem
+
+Flycheck's status (error / warning counts, "checking" indicator) is not
+visible in the custom modeline. The cause is a deliberate choice in
+=modules/modeline-config.el=: =mode-line-format= is built from explicit
+segments (=cj/modeline-buffer-name=, =cj/modeline-position=,
+=cj/modeline-vc-branch=, etc.) and does not include =minor-mode-alist=
+or =mode-line-modes=. Flycheck publishes its lighter into
+=minor-mode-alist=, so the custom modeline never picks it up.
+
+The fix is to add a flycheck-aware segment to =mode-line-format=.
+
+* Goals
+
+1. Flycheck status appears in the custom modeline when =flycheck-mode= is on.
+2. The display picks up flycheck's existing color logic (error count in =error= face, warning count in =warning= face).
+3. The display gates on active window, matching the convention used by =cj/modeline-vc-branch= and =cj/modeline-misc-info=.
+4. The customization is small enough that swapping prefix / success indicator is a one-line edit.
+
+* Non-Goals
+
+- A "minor modes" segment that surfaces every lighter from =minor-mode-alist=. Flycheck is the specific case we care about; the rest stay invisible.
+- Reworking =flycheck-config.el= beyond the two =:custom= additions.
+- Adding flycheck-side checkers or changing what gets checked.
+
+* Current State
+
+** =modules/flycheck-config.el:47-97=
+
+#+begin_src emacs-lisp
+(use-package flycheck
+ :defer t
+ :commands (flycheck-list-errors cj/flycheck-list-errors)
+ :hook ((sh-mode emacs-lisp-mode) . flycheck-mode)
+ :bind (:map cj/custom-keymap ("?" . cj/flycheck-list-errors))
+ :custom
+ (checkdoc-arguments
+ '(("sentence-end-double-space" nil)
+ ("warn-escape" nil)))
+ :config
+ ...)
+#+end_src
+
+No flycheck-modeline customization. Defaults are in force:
+
+| Variable | Default |
+|-------------------------------------+-------------------------------------------|
+| =flycheck-mode-line-prefix= | ="FlyC"= |
+| =flycheck-mode-success-indicator= | =":0"= |
+| =flycheck-mode-line-color= | =t= (apply error / warning faces) |
+| =flycheck-mode-line= | ='(:eval (flycheck-mode-line-status-text))= |
+
+** =modules/modeline-config.el:220-237=
+
+=mode-line-format= layout (left → right, with right-align edge):
+
+#+begin_src emacs-lisp
+(setq-default mode-line-format
+ '("%e"
+ " "
+ cj/modeline-major-mode
+ " "
+ cj/modeline-buffer-name
+ " "
+ cj/modeline-position
+ mode-line-format-right-align
+ (:eval (when (fboundp 'cj/recording-modeline-indicator)
+ (cj/recording-modeline-indicator)))
+ cj/modeline-vc-branch
+ " "
+ cj/modeline-misc-info
+ " "))
+#+end_src
+
+Risky-local-variable list (=modeline-config.el:240-246=):
+
+#+begin_src emacs-lisp
+(dolist (construct '(cj/modeline-buffer-name
+ cj/modeline-position
+ cj/modeline-vc-branch
+ cj/modeline-vc-faces
+ cj/modeline-major-mode
+ cj/modeline-misc-info))
+ (put construct 'risky-local-variable t))
+#+end_src
+
+Note: =cj/modeline-vc-branch= and =cj/modeline-misc-info= both gate on
+=(mode-line-window-selected-p)= so they appear only in the active window.
+
+** Flycheck lighter outputs (for reference)
+
+Flycheck status text values that =flycheck-mode-line-status-text=
+returns, depending on =flycheck-last-status-change= and current errors:
+
+| Status | Display (with default prefix / indicator) |
+|------------------------------+----------------------------------------------------|
+| Not yet checked | =FlyC= |
+| Currently checking | =FlyC*= |
+| Finished, no errors | =FlyC:0= |
+| Finished, 3 errors, 5 warns | =FlyC:3|5= |
+| Checker errored | =FlyC!= |
+| Interrupted | =FlyC.= |
+| Suspicious | =FlyC?= |
+| No checker available | =FlyC-= |
+
+With =flycheck-mode-line-color= = =t= (the default), the count portion
+is colored: error count in the =error= face, warning count in =warning=.
+
+* Approaches Considered
+
+** Option 1 (Reject): customize prefix / indicator only
+
+Setting =flycheck-mode-line-prefix= and =flycheck-mode-success-indicator=
+in =:custom= changes the lighter content, but the lighter still publishes
+to =minor-mode-alist=, which the custom modeline doesn't read. The lighter
+becomes prettier wherever it does show (e.g. doom-modeline if reinstated)
+but not here. Doesn't solve the visibility problem.
+
+** Option 2 (Reject): add the raw =flycheck-mode-line= variable
+
+Inserting =flycheck-mode-line= into =mode-line-format= directly works,
+but the form has no =flycheck-mode= guard. In a buffer where flycheck
+isn't loaded or not enabled, the =:eval (flycheck-mode-line-status-text)=
+call still fires and either errors or returns junk. Needs a wrapping
+guard, which is what Option 4 does.
+
+** Option 3 (Reject for now): custom segment with full control
+
+Define =cj/modeline-flycheck= as a =defvar-local= holding a =(:eval ...)=
+form that pulls error / warning counts directly from
+=flycheck-current-errors=, builds a per-status string, propertizes it
+with =error= / =warning= faces, and returns it. Reimplements what
+=flycheck-mode-line-status-text= already does, with bespoke formatting.
+
+Pros: full control over format. Cons: maintenance burden, drifts from
+flycheck's status model if flycheck changes it.
+
+If the Option 4 result ever stops being good enough -- e.g. you want a
+different layout (=E:3 W:5= instead of =:3|5=) -- come back to this.
+Until then, more code than the problem deserves.
+
+** Option 4 (Recommended): hybrid -- customize variables + add guarded segment
+
+Two changes:
+
+1. =modules/flycheck-config.el= =:custom= block gets prefix and success-indicator overrides. (Optional: also =flycheck-mode-line-color=.)
+
+2. =modules/modeline-config.el= adds a small =(:eval ...)= form inline in =mode-line-format= that guards on =flycheck-mode= and calls =(flycheck-mode-line-status-text)= directly.
+
+Pros: minimal code, uses flycheck's logic verbatim, prefix / indicator
+swappable with a one-line edit, picks up flycheck's face colors
+automatically.
+
+Cons: layout fixed to flycheck's =PREFIX[indicator|counts]= shape.
+Acceptable.
+
+* Recommended Implementation (Option 4)
+
+** Step 1: =modules/flycheck-config.el=
+
+Add to the =:custom= block (currently lines 55-59 in the file):
+
+#+begin_src emacs-lisp
+;; Modeline customization (rendered via mode-line-format in modeline-config.el).
+(flycheck-mode-line-prefix "🐛")
+(flycheck-mode-success-indicator " ✓")
+;; flycheck-mode-line-color stays t (default) so counts keep their face coloring.
+#+end_src
+
+Prefix and success indicator are taste; the **Emoji Reference** section
+below catalogs the candidates. Note that the prefix emoji itself does
+not inherit the =error= / =warning= face -- only the count portion does
+(via =flycheck-mode-line-color=). That trade-off is fine for a static
+prefix; an emoji prefix gives a recognizable shape that you scan for,
+and the colored count carries the alert signal.
+
+** Step 2: =modules/modeline-config.el=
+
+Insert a =(:eval ...)= form into =mode-line-format= (currently lines
+220-237). Recommended placement: between the recording indicator and
+=cj/modeline-vc-branch= so flycheck status sits with the other
+right-aligned status segments.
+
+After the change, the right-side block reads:
+
+#+begin_src emacs-lisp
+;; RIGHT SIDE
+mode-line-format-right-align
+(:eval (when (fboundp 'cj/recording-modeline-indicator)
+ (cj/recording-modeline-indicator)))
+(:eval (when (and (mode-line-window-selected-p)
+ (bound-and-true-p flycheck-mode))
+ (flycheck-mode-line-status-text)))
+" "
+cj/modeline-vc-branch
+" "
+cj/modeline-misc-info
+" ")
+#+end_src
+
+Two design choices baked in:
+
+- =(mode-line-window-selected-p)= gates the segment to the active window, matching the convention used by =cj/modeline-vc-branch= and =cj/modeline-misc-info=.
+- =(bound-and-true-p flycheck-mode)= prevents the function call in buffers where flycheck never loaded; safer than asking =flycheck-mode= directly.
+
+** Risky-local-variable: not needed here
+
+This implementation places =(:eval ...)= inline inside =mode-line-format=
+rather than wrapping it in a =defvar-local=. Inline forms are evaluated
+by mode-line processing without a risky-local-variable marker. The
+existing risky list (=modeline-config.el:240-246=) does not need to
+grow.
+
+(If you ever refactor this to a named segment -- =defvar-local cj/modeline-flycheck= -- then add it to the risky list. Option 3 above is the path that needs that step.)
+
+* Emoji Reference
+
+** Prefix candidates (=flycheck-mode-line-prefix=)
+
+| Glyph | Codepoint | Name |
+|-------+-----------+---------------------------------------|
+| 🪰 | U+1FAB0 | FLY (literal "fly" for flycheck) |
+| 🐛 | U+1F41B | BUG (recommended -- broadest font support) |
+| 🐞 | U+1F41E | LADY BEETLE |
+| ⚠ | U+26A0 | WARNING SIGN |
+| 🔍 | U+1F50D | MAGNIFYING GLASS |
+| 📝 | U+1F4DD | MEMO |
+| ✓ | U+2713 | CHECK MARK (text) |
+
+🪰 (U+1FAB0) is from Unicode 13.0 (2020) and needs an up-to-date emoji
+font. 🐛 (U+1F41B) is older and renders everywhere. Default to 🐛 unless
+the fly is a strong preference and the GUI fonts are known to cover it.
+
+** Success indicator candidates (=flycheck-mode-success-indicator=)
+
+| Glyph | Codepoint | Name |
+|-------+-----------+---------------------------------------|
+| ✓ | U+2713 | CHECK MARK (text) |
+| ✔ | U+2714 | HEAVY CHECK MARK |
+| ✅ | U+2705 | WHITE HEAVY CHECK MARK (green box) |
+| 🟢 | U+1F7E2 | GREEN CIRCLE |
+| ⭐ | U+2B50 | WHITE MEDIUM STAR |
+
+Note the leading space in the recommended setting (=" ✓"=): flycheck
+joins the prefix and the success indicator with no separator, so a
+leading space in the indicator gives breathing room between the emoji
+prefix and the check mark.
+
+** Suggested combinations
+
+| Mood | Prefix | Success indicator | Result example |
+|---------------------+--------+-------------------+----------------|
+| Recommended default | 🐛 | " ✓" | =🐛 ✓= / =🐛:3|5= |
+| Literal Flycheck | 🪰 | " ✓" | =🪰 ✓= / =🪰:3|5= |
+| Minimal | "" | " ✓" | = ✓= / =:3|5= |
+| Status light | "" | " 🟢" | = 🟢= / =:3|5= |
+
+* Testing
+
+** Manual
+
+1. Open =modules/flycheck-config.el= (an =emacs-lisp-mode= buffer with =flycheck-mode= auto-enabled per the existing =:hook=). The right side of the modeline shows the prefix + success indicator when there are no errors.
+2. Introduce a deliberate parse error (drop a paren). Save. The modeline updates to show =:1|0= (or whatever count) in the =error= face.
+3. Trigger =M-x flycheck-buffer= in a fresh =sh-mode= buffer. The "currently checking" state (=PREFIX*=) flashes briefly before settling on success or counts.
+4. Open a second window onto the same buffer (=C-x 2=). The flycheck segment appears in the active window only; the inactive copy drops it. Confirms the active-window gate.
+5. Open a buffer where flycheck never engages (e.g. =*scratch*= in fundamental-mode, or a =dired= buffer). No segment, no errors.
+6. Run =cj/flycheck-prose-on-demand= in an org buffer (=C-; ?= in org-mode). The LanguageTool checker engages and the segment appears with prose-error counts.
+
+** Regression watch
+
+- The custom-modeline width should not jump distractingly as flycheck cycles "checking → finished". The status text is short (one to seven chars), so this should be invisible -- worth a glance.
+- Inactive-window display: confirm the segment disappears, not just greys out. The current pattern is "hide entirely" via the =mode-line-window-selected-p= guard.
+- =cj/modeline-misc-info= keeps showing chime / notification text. The flycheck segment sits to its left; verify the visual order matches the spec.
+
+* Files to Modify
+
+- =modules/flycheck-config.el= -- add two =:custom= lines.
+- =modules/modeline-config.el= -- insert one =(:eval ...)= form into =mode-line-format=.
+
+Two-line / one-form change. No new tests required (the existing tests
+don't lock the modeline content; they exercise behavior elsewhere). If
+you want a smoke test, add one assertion in =tests/test-modeline-config.el=
+(if that file exists or you create it) that =mode-line-format='s sexp
+contains a form mentioning =flycheck-mode-line-status-text=. Optional.
+
+* Risks
+
+| Risk | Mitigation |
+|-----------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------------------|
+| Emoji renders as a tofu square in terminal Emacs | The user runs GUI Emacs primarily; if terminal use matters, set the prefix to a text glyph (=""= or =":"=) instead. |
+| Modeline width thrash when flycheck transitions running → finished | Status text is one to seven chars; jitter is negligible. Confirm during manual testing. |
+| Prefix emoji doesn't pick up =error= / =warning= face | Expected: =flycheck-mode-line-color= colors the count portion only. The static prefix is intentionally unstyled. If you want a colored prefix, switch to Option 3. |
+| Flycheck not yet loaded when modeline first evaluates | The =(bound-and-true-p flycheck-mode)= guard returns nil in that case, the =(:eval ...)= returns nil, mode-line skips the slot. |
+| Active-window gate is wrong for some workflow (e.g. multi-window comparison) | Drop =(mode-line-window-selected-p)=. One-line change. Decide after living with the default. |
+
+* Rollback
+
+Revert the commit. Two-file change, no schema impact. Idempotent.
+
+* Effort estimate
+
+S (under 1 hour). Two lines in =flycheck-config.el=, one form in
+=modeline-config.el=, plus the manual verification walk-through. The
+emoji selection is the time sink, not the code.
diff --git a/docs/specs/gloss-spec-doing.org b/docs/specs/gloss-spec-doing.org
new file mode 100644
index 000000000..320b83ebf
--- /dev/null
+++ b/docs/specs/gloss-spec-doing.org
@@ -0,0 +1,320 @@
+:PROPERTIES:
+:ID: 295f9969-ccef-4df9-945b-9e08d8069daf
+:STATUS: doing
+:END:
+#+TITLE: Design — gloss (Glossary Lookup with Online-Sourced Selection)
+#+DATE: 2026-04-28
+#+STATUS: Draft
+
+* Problem
+
+A personal glossary inside Emacs, modelled on the existing =quick-sdcv= UX (=C-h d=) but for self-curated terms rather than packaged dictionaries. =C-h g= prompts for a term (defaulting to word-at-point), looks it up in a single git-tracked org file, and shows the definition in a side buffer that =q= dismisses. On a local miss, the package fetches candidate definitions from an online source, lets the user pick one, and saves it with provenance. The same org file feeds =org-drill= for spaced-repetition study.
+
+The pain point: domain jargon — government acronyms, technical terms, philosophy vocabulary, project-specific names — doesn't live in any general dictionary, so existing tools like =quick-sdcv= can't help. A personal glossary that grows by use (encounter term → save it once → it's permanently looked-up-able and study-card-able) closes that gap.
+
+* Non-Goals
+
+The following are explicitly out of scope for v1. Each is a defensible v2+ topic on its own.
+
+- *Multi-language support.* English only. Wiktionary returns French/Latin/etc. — v1 ignores everything but the =en= key.
+- *Synonyms, cross-references, related terms.* Even when the upstream source returns them, v1 stores only the picked definition.
+- *Audio pronunciation.* Not fetched, not played.
+- *Etymology, usage notes, parsed examples.* Discarded during HTML strip.
+- *Multiple glossaries / domain separation.* One file, one glossary.
+- *Backup or sync infrastructure.* Delegated to git on whatever path =gloss-file= points at.
+- *Org-drill scheduling control.* The exporter prepares entries; =org-drill= itself runs unmodified.
+
+In scope (kept after triage): edit-in-place via =C-h g e=, which jumps to the source file at the entry's heading.
+
+* Approaches Considered
+
+Six approaches evaluated during brainstorm. Three conventional, three tail samples for diversity.
+
+** Recommended: Layered multi-module package
+
+Five =.el= files, each owning one concern: =gloss-core= (data), =gloss-fetch= (network), =gloss-display= (UI), =gloss-drill= (drill export), =gloss= (orchestration entry point). Each layer mocks at its own natural boundary; no layer mocks another layer's internals.
+
+*Why this over the alternatives.* The codebase already prefers layering — =coverage-core= + =coverage-elisp= split, Hugo pure-helpers + interactive wrappers, LSP file-watch defvar + function. The four concerns (data, fetch, display, drill) have genuinely different test boundaries (file I/O, HTTP, mode UI, =org-element=). Mixing them in one file would force overmocking, which the project's testing rules flag as a smell. The package is also public-style — clear module boundaries reward cold readers.
+
+*What's traded away.* About 30 minutes more structural setup at the start, in exchange for boilerplate that may never pay off if the package stays personal forever. Cheap trade against the testing and reading wins.
+
+** Rejected: Single-file quick-sdcv-clone
+
+One =.el= file (~400 lines) covering all four concerns. Simplest path, lowest dependency footprint, but everything (data, HTTP, mode definition, drill) cohabits a single namespace. Test isolation gets awkward; refactor cost grows when one piece needs replacing.
+
+** Rejected: Backend-pluggable registry
+
+A =glossary-backend= protocol covering both local-org and online sources, with =lookup= / =save= / =list= operations. Local and online become interchangeable backends. Real future-proofing, but for v1 with two backends and probably never a third, the protocol is overkill — YAGNI risk. The forward-compat shape we did adopt (the =gloss-fetch-sources= registry, see Architecture) gets the same benefit at a fraction of the design weight, scoped only to where source variety is real.
+
+** Rejected: quick-sdcv + generated StarDict
+
+Round-trip the org file through StarDict format on save; reuse =quick-sdcv='s UI verbatim. Reuses 100% of an existing UI but loses provenance metadata in the round-trip, fights drill (which reads org, not StarDict), and forces a binary intermediate format for what should be a plain-text data store.
+
+** Rejected: Org-roam node per term
+
+Each entry is its own =org-roam= node. Free fuzzy/exact title search, free backlinks. But it's a heavy dependency for an otherwise self-contained package, file-explodes (1000 terms = 1000 files), and contradicts the locked single-file storage decision.
+
+** Rejected: Lazy-reactive minor mode
+
+Passive recognition — =gloss-mode= scans buffer text for known terms, underlines them, hover/click reveals definitions. Different and arguably more-natural mental model, but it reframes the brief (active =C-h g= lookup is what was asked for) and doesn't naturally support online fallback or auto-add. Probably belongs as a v3 feature on top of the layered architecture, not as the architecture itself.
+
+* Design
+
+** Architecture
+
+Five =.el= files:
+
+#+begin_example
+gloss-core.el data layer — org file I/O + in-memory cache
+gloss-fetch.el network layer — Wiktionary REST + HTML strip
+gloss-display.el UI layer — side buffer + picker
+gloss-drill.el drill export — :drill: tag + twosided property
+gloss.el entry point — defcustoms, prefix keymap, user commands
+#+end_example
+
+*Public API by layer.*
+
+=gloss-core=: =gloss-core-lookup TERM=, =gloss-core-save TERM DEFINITION SOURCE=, =gloss-core-list=, =gloss-core-find-buffer-position TERM=.
+
+=gloss-fetch=: =gloss-fetch-definitions TERM= → =(:ok DEFS) | (:empty :no-defs SOURCES :failed SOURCES)=. Internally a registry: =gloss-fetch--sources= alist (source-symbol → fetcher function), walked in order per the user-facing =gloss-fetch-sources= defcustom.
+
+=gloss-display=: =gloss-display-show-entry TERM BODY=, =gloss-display-pick-definition TERM DEFINITIONS=. Defines =gloss-mode= (derived from =special-mode=, =q= quits).
+
+=gloss-drill=: =gloss-drill-export-all=, =gloss-drill-untag-all=. Operates on the org file via =org-element=.
+
+=gloss=: =defcustom gloss-file= (path), =gloss-prefix-map= for =C-h g=, user commands =gloss-lookup=, =gloss-add=, =gloss-edit=, =gloss-fetch-online=, =gloss-drill-export=.
+
+** Data Flow
+
+*Shapes.*
+
+A definition (in flight from fetch through display to save) is a plist:
+
+#+begin_src emacs-lisp
+(:source wiktionary :text "Reference to something earlier in the discourse...")
+#+end_src
+
+An entry (saved in cache and on disk) is a plist:
+
+#+begin_src emacs-lisp
+(:term "anaphora"
+ :body "Reference to something earlier in the discourse..."
+ :source wiktionary
+ :added "2026-04-28"
+ :marker #<marker at 1247 in gloss.org>)
+#+end_src
+
+The cache is a hash table, term-string → entry-plist. The org file is the source of truth; the cache is a read-side index.
+
+*Lookup flow (=C-h g=).*
+
+1. Read input — word-at-point if available, else minibuffer prompt.
+2. =gloss-core-lookup TERM=. Cache loaded if cold.
+3. Hit → =gloss-display-show-entry=. Done.
+4. Miss → silent fall-through to =gloss-fetch-definitions TERM=.
+5. Orchestrate on result:
+ - 0 definitions or all-failures → side buffer message (see Error Handling).
+ - 1 definition → auto-save via =gloss-core-save=, then =gloss-display-show-entry=.
+ - >1 definitions → =gloss-display-pick-definition= → user picks → =gloss-core-save= → =gloss-display-show-entry=.
+
+*Add flow (=C-h g a=).*
+
+=gloss-add= prompts for term and body (small temp buffer for multi-line body, =C-c C-c= accepts). =gloss-core-save TERM BODY 'manual=. Then =gloss-display-show-entry=.
+
+*Edit flow (=C-h g e=).*
+
+=gloss-edit= resolves the term to a buffer position via =gloss-core-find-buffer-position=. Opens the org file at that heading in the *source* buffer (not the side buffer). User edits inline. On save, the buffer-local =after-save-hook= refreshes the cache for that single term.
+
+*Drill export (=C-h g D=).*
+
+=gloss-drill-export-all= walks the org file via =org-element=, ensures every term heading has =:drill:= tag and =:DRILL_CARD_TYPE: twosided= property. =M-x org-drill= runs the session — gloss does not wrap or invoke =org-drill= itself.
+
+** Persistence
+
+*File shape.* Single org file at =gloss-file= (default: =(expand-file-name "gloss.org" (or org-directory user-emacs-directory))=). One =* term= heading per entry, alphabetical order maintained on insert. Each entry has a =:PROPERTIES:= drawer with =:SOURCE:= and =:ADDED:=. Body is plain text immediately under the heading.
+
+#+begin_example
+#+TITLE: Glossary
+#+STARTUP: showall
+
+* anaphora
+:PROPERTIES:
+:SOURCE: wiktionary
+:ADDED: 2026-04-28
+:END:
+Reference to something earlier in the discourse...
+
+* SBIR
+:PROPERTIES:
+:SOURCE: wiktionary
+:ADDED: 2026-04-28
+:END:
+Initialism of Small Business Innovation Research...
+#+end_example
+
+After =gloss-drill-export-all=, the heading line gains a =:drill:= tag and the properties drawer gains =:DRILL_CARD_TYPE: twosided=.
+
+*Cache lifecycle.* Hash table loaded lazily on first lookup of the session. Populated by reading =gloss-file= once and parsing with =org-element-parse-buffer=. Subsequent lookups hit the cache directly.
+
+*Cache invalidation.* Four triggers, in order of cost:
+
+1. =gloss-core-save= mutates the cache directly when it writes.
+2. *mtime check on every lookup.* =file-attributes= the file before each =gloss-core-lookup= returns; if mtime > cached-mtime, reload before answering. Sub-millisecond cost; catches every out-of-band edit (other Emacs session, =git pull=, hand-edit, =sed=).
+3. =gloss-edit='s buffer-local =after-save-hook= updates the single edited term immediately; overlaps with #2 but doesn't wait for the next lookup.
+4. Manual =gloss-reload= command — nuclear option for paranoia.
+
+=file-notify-add-watch= rejected: platform-specific backend, async callback complicates the model, mtime path is already sub-millisecond.
+
+*Write strategy.* Append-on-add via direct buffer editing (=find-file-noselect=, insert at the alphabetically-correct heading position, save, kill the buffer if not previously open). No journal, no temp file — org-mode's =auto-save-mode= and the user's git tracking provide durability. Single-user, single-Emacs assumed; concurrent access isn't a concern.
+
+*Alphabetical order.* Maintained on insert via case-insensitive string compare. Cheap; the file stays diff-clean (only the inserted block changes).
+
+** Error Handling
+
+*Per-source status taxonomy.* Five internal values; three user-facing rollups.
+
+#+begin_src emacs-lisp
+;; Internal per-source result:
+(:source SYM :status STATUS :reason STRING)
+
+;; STATUS values:
+;; :ok :defs (def1 def2 ...) — success
+;; :no-defs — server reached, term not there (HTTP 404 or empty 200)
+;; :unreachable — network problem (DNS, refused, timeout)
+;; :server-error — HTTP 5xx, malformed JSON, schema mismatch, HTTP 4xx other than 404/429
+;; :rate-limited — HTTP 429
+#+end_src
+
+*=:reason= strings* carry the technical detail (=timeout (5s)=, =HTTP 503=, =malformed JSON: ...=) and land in =*gloss-debug*=. They are never user-facing.
+
+*User-facing rollup.* =gloss-fetch-definitions= aggregates per-source results into:
+
+#+begin_src emacs-lisp
+(:ok DEFS) ;; any source returned >=1 def
+(:empty :no-defs (...) :failed (...)) ;; everything else
+#+end_src
+
+=:failed= unions =:unreachable=, =:server-error=, =:rate-limited=.
+
+| Result shape | Message |
+|-------------------------------------------+--------------------------------------------------------------------|
+| Every source =:no-defs=, none failed | "No definition for X in Wiktionary." |
+| Every source failed, none =:no-defs= | "Couldn't reach Wiktionary." |
+| Mix of =:no-defs= and failures | "No definition in Wiktionary; couldn't reach DictionaryAPI." |
+| Any =:ok= with defs | Silent on others — picker shows what came back |
+
+When v2 starts surfacing =:rate-limited= regularly, the rollup wording will gain a third visible category. v1 with no-key Wiktionary doesn't need it.
+
+*libxml as a precondition, not a per-source failure.* First time =gloss-fetch-definitions= runs, probe =(libxml-parse-html-region 1 1)= on a temp buffer. If unavailable, online fetching is disabled package-wide for the session with a one-shot =user-error=: "Online fetch requires Emacs built with libxml2; manual add still works." Subsequent online attempts in the session short-circuit to that message.
+
+*Partial-success on per-sense HTML failures.* If libxml is available but fails on a specific sense's content, drop that sense and return the rest. Source status stays =:ok= with N-1 entries; the dropped sense logs to =*gloss-debug*=. A single bad sense doesn't poison the whole source.
+
+*Storage failures.* First call creates =gloss-file= and any missing parent directory with a =#+TITLE: Glossary= header. Permission denied raises =user-error= naming the path. Corrupt org file (=org-element-parse-buffer= raises) preserves the existing cache and surfaces "glossary file corrupt at line N; cache not refreshed" — operations fall back to the stale cache until the user fixes the file and runs =gloss-reload=. Term collision (saving an existing term) prompts: replace, append-with-separator, or cancel.
+
+*Drill.* =org-drill= checked via =featurep= before export runs. If absent: =user-error= with install hint.
+
+*User cancellations.* =C-g= during the picker → no save, side buffer shows the local-miss state. Empty term input from =gloss-add= → re-prompt once, then abort silently. Cancelled at the term-collision prompt → no write.
+
+** Testing
+
+Per-function test files; three categories (Normal/Boundary/Error) per function. TDD by default. Real production code via =require=, never inlined.
+
+*=gloss-core=.* Temp files + real =org-element-parse-buffer=. No mocking — exercises the actual file I/O and parser.
+
+#+begin_example
+test-gloss-core--lookup.el
+test-gloss-core--save.el
+test-gloss-core--invalidate-on-mtime.el
+test-gloss-core--corrupt-file-preserves-cache.el
+test-gloss-core--alphabetical-insert.el
+test-gloss-core--first-call-creates-file.el
+#+end_example
+
+*=gloss-fetch=.* =cl-letf= mock on =url-retrieve-synchronously=, injecting canned response buffers. Captured Wiktionary fixtures in =tests/fixtures/wiktionary-*.json= — real responses for SBIR, anaphora, API, frozen once, replayed forever.
+
+#+begin_example
+test-gloss-fetch--definitions-200-returns-ok.el
+test-gloss-fetch--definitions-404-returns-no-defs.el
+test-gloss-fetch--definitions-500-returns-server-error.el
+test-gloss-fetch--definitions-timeout-returns-unreachable.el
+test-gloss-fetch--strip-html.el
+test-gloss-fetch--multi-source-walks-registry.el
+test-gloss-fetch--libxml-probe.el
+#+end_example
+
+*=gloss-display=.* The candidate-formatting helper =gloss-display--format-candidate PLIST → "[wiktionary] text..."= is pure → full N/B/E coverage. =gloss-display-show-entry= and =gloss-mode= get one smoke test each (Emacs already tests =switch-to-buffer= and major-mode definition).
+
+#+begin_example
+test-gloss-display--format-candidate.el
+test-gloss-display--show-entry-smoke.el
+#+end_example
+
+*=gloss-drill=.* Temp file + real =org-element=. Tests assert tag/property changes on entries.
+
+#+begin_example
+test-gloss-drill--export-all-tags-untagged.el
+test-gloss-drill--export-all-skips-already-tagged.el
+test-gloss-drill--export-all-no-orgdrill-installed.el
+test-gloss-drill--untag-all.el
+#+end_example
+
+*=gloss=.* The orchestration policy =gloss--orchestrate-fetch-result RESULT → SYMBOL= is a pure pattern-matcher. Tested with shaped inputs covering every result variant.
+
+#+begin_example
+test-gloss--orchestrate-fetch-result.el
+#+end_example
+
+*Integration tests.* Three small ones, each with a docstring naming participants per project convention.
+
+#+begin_example
+test-integration-gloss-lookup-flow-local-hit.el
+test-integration-gloss-lookup-flow-online-fall-through.el
+test-integration-gloss-lookup-flow-online-failure.el
+#+end_example
+
+*Coverage targets.* 90%+ on =gloss-core=, =gloss-fetch=, =gloss-drill=, and pure helpers in =gloss-display= / =gloss=. 70%+ on display mode-glue. Overall ≥80%.
+
+** Observability
+
+*=*gloss-debug*= log buffer.* Off until =gloss-debug= defcustom is non-nil, or session-only =gloss-toggle-debug= flips it. One timestamped, layer-prefixed line per significant event.
+
+#+begin_example
+2026-04-28 11:14:02 [fetch:wiktionary] GET /API → 200, 12 senses
+2026-04-28 11:14:02 [fetch:wiktionary] sense 7 HTML parse failed, dropping
+2026-04-28 11:14:02 [core] cache hit for "anaphora"
+2026-04-28 11:14:09 [core] mtime change detected, reloading cache (47 terms)
+2026-04-28 11:14:11 [save] "API" → wiktionary, 11 alts not saved
+#+end_example
+
+Per-source statuses from Error Handling land here verbatim. No personal data beyond user-supplied terms.
+
+*=*Messages*= for user-facing events.* Saves, picker-shown, "no definition found" messages — short single-line =message= calls, persisted in =*Messages*= via Emacs idiom. Strict separation: =*Messages*= for things the user did or asked for; =*gloss-debug*= for everything else.
+
+*Inspection commands.*
+
+- =gloss-list-terms= — completing-read over every term in the cache. Pick one to jump to it.
+- =gloss-stats= — small buffer summarizing total terms, breakdown by =:source=, count of drill-tagged entries, file size, cache mtime.
+
+No metrics export, no telemetry, no profiling hooks — v3 territory if the package ever needs them.
+
+* Open Questions (will become ADRs)
+
+Each was decided during the brainstorm. Listed for traceability; each becomes an ADR in the gloss repo's =docs/decisions/=.
+
+- [ ] *ADR-1: storage path default* → =(expand-file-name "gloss.org" (or org-directory user-emacs-directory))=. Rationale: respects the user's existing =org-directory= convention; falls back gracefully.
+- [ ] *ADR-2: auto-fetch on local miss* → silent fall-through with graceful network-failure path. Rationale: y/n prompt is yes 99% of the time and an annoyance the other 1%; the offline case is better handled by detecting the failure than by pre-asking permission.
+- [ ] *ADR-3: drill direction* → =:DRILL_CARD_TYPE: twosided=. Rationale: tests both recognition and recall over time without doubling the deck.
+- [ ] *ADR-4: HTML strip strategy* → =libxml-parse-html-region= (plain text only, no italic/bold preservation). Rationale: more robust than regex on edge cases; libxml2 is standard on Linux/Mac; ~30 lines.
+
+* Next Steps
+
+1. *Scaffold the repo.* =~/code/gloss= with the claude-template structure: =.ai/= and =todo.org= and =inbox/= gitignored, =Makefile= for tests/lint/compile, =README.org= placeholder, =LICENSE=, package skeleton (=gloss.el= with package-header autoload entry).
+2. *Set up remotes.* Bare repo on cjennings.net at =/var/cjennings/git/gloss.git/= with the existing post-receive hook pattern that mirrors to =github.com/cjennings/gloss=.
+3. *Decompose into todo.org tasks.* One TODO per layer, in implementation order: core → fetch → display → drill → entry-point → integration tests → README. Each task carries its acceptance criteria from this design.
+4. *Implement v1 layer by layer*, TDD per project rules. Run =/start-work= once per task.
+5. *First-week shakedown.* Use the package on real terms for a week. File issues against any rough edges as v1.1 tasks.
+6. *Record the four ADRs* in =docs/decisions/= once the repo exists.
+
+* Status
+
+Draft. Pending: repo scaffold, ADR records, implementation.
diff --git a/docs/specs/gptel-gh-tool-spec.org b/docs/specs/gptel-gh-tool-spec.org
new file mode 100644
index 000000000..80ecc0ab6
--- /dev/null
+++ b/docs/specs/gptel-gh-tool-spec.org
@@ -0,0 +1,1065 @@
+:PROPERTIES:
+:ID: a124dd0f-1f40-4533-aeb8-595d93e20865
+:STATUS: not-started
+:END:
+#+TITLE: Design: Wrap the gh CLI as a GPTel tool
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft (revision 2). Pre-implementation; no code shipped yet. =gh=
+v2.92.0 installed and authenticated against both =github.com= (account
+=cjennings=) and =deepsat.ghe.com= (account =craig-jennings=).
+
+Revision 2 incorporates a code-review pass that caught several
+incorrect =gh= CLI assumptions and a critical safety gap: with
+=gptel-confirm-tool-calls= set to =nil= in =modules/ai-config.el:386=,
+the "irreversible-only blocklist" V1 from revision 1 would let the
+agent run unconfirmed writes (=pr merge=, =run cancel=, =api -f
+body=...=, =release create=, etc.). Revision 2 keeps Craig's intent
+of a general-capability tool but applies =:confirm t= to every
+classified write, drops the "scan command string for =--hostname=
+and =-H=" approach in favor of argv-list builders, and expands the
+wrapper set so most agent workflows don't need to fall back to the
+general tool.
+
+* Problem
+
+GPTel agents can read git history locally (=git_log=, =git_diff=,
+=git_status=) but have no access to the GitHub side of the workflow:
+PRs, issues, reviews, CI runs, releases, gists, repo metadata. The
+=gh= CLI does all of this and is already authenticated against both
+hosts the user works with. Wrapping it as a GPTel tool gives the
+agent the same GitHub surface the user has.
+
+The wrapper has to handle four complications the local git tools
+don't:
+
+1. *Two authenticated hosts* -- personal (=github.com=) and work
+ GHE (=deepsat.ghe.com=) -- with different policies and different
+ blast radii for destructive operations.
+2. *A vast subcommand surface* (~30 top-level subcommands, hundreds
+ of leaves) that doesn't fit cleanly into one typed schema per
+ leaf.
+3. *Inconsistent flag semantics across subcommands.* =--hostname=
+ exists only on =gh api= and =gh auth status=; the rest use
+ =--repo [HOST/]OWNER/REPO=. =-H= means =--head= on =gh pr list=
+ but =--header= on =gh api=. Naive string-scanning to detect
+ user intent is wrong.
+4. *Global GPTel setting* (=gptel-confirm-tool-calls nil=) means
+ the agent can call any registered tool without user confirmation
+ unless that specific tool is registered with =:confirm t=. V1
+ safety must come from per-tool flags, not from a session-level
+ gate.
+
+* Goals
+
+1. The agent can invoke the non-interactive subset of =gh= against
+ either host, gated by a safety policy.
+2. High-frequency reads have typed wrappers with sensible defaults
+ (host/repo resolution, JSON-by-default with minimal fields,
+ output cap, timeout). Wrappers auto-execute.
+3. The active host is resolved from a single context object that
+ considers (in order): explicit =hostname=/=repo= args, branch
+ upstream remote, =origin= remote, =cj/gh-default-host=. Every
+ tool response prefixes the resolved host/repo so cross-host
+ mistakes are visible.
+4. Writes via the general tool execute only after user
+ confirmation (=:confirm t= on the gptel tool registration).
+ Unknown classifications fail closed (=:confirm t=).
+ Irreversible commands hard-block.
+5. Interactive commands (=auth login=, =codespace ssh=, =--web=,
+ =browse=, =pr checkout=, =repo clone=, =config set=,
+ =alias set=, =extension exec=) are hard-blocked in V1.
+6. File exfiltration paths (=api --input=, =api -F key=@file=,
+ =release upload=, =gist create=) are hard-blocked in V1.
+7. Output is capped *during capture*, not after, so a runaway
+ =--paginate= or =--log= can't fill memory or block Emacs.
+ Long-output commands have higher timeouts but the same byte
+ cap.
+8. Every call produces a structured debug record (host, repo, cwd,
+ sanitized argv, classification, policy decision, exit code,
+ duration, bytes captured, truncation flag, error kind) inspectable
+ via =cj/gh-tool-last-error=.
+9. =cj/gh-doctor= diagnoses missing prerequisites (=gh=
+ executable, version floor, auth per host, cwd repo detection,
+ environment overrides) before they fail at runtime.
+10. V2 adds true per-host policy (currently uniform across both
+ hosts), profile-based tool subsets, and bridge helpers between
+ local git tools and the gh wrappers.
+
+* Non-Goals
+
+- *Interactive subcommands.* =gh auth login=, =gh codespace ssh=,
+ =gh pr checkout= (modifies working tree), =gh repo clone=
+ (writes outside controlled paths) are not in scope. The user
+ runs those in a real terminal.
+- *Replicating gh extensions.* Core gh only. Extensions can be
+ invoked from a regular terminal if needed.
+- *Streaming long-running output.* =gh run watch=, =gh
+ --paginate= for unbounded result sets, =gh repo clone= for large
+ repos. V1 blocks =--paginate= and =watch= verbs; future
+ versions may add streaming support.
+- *Per-host write policies* (work-read-only / personal-full-write).
+ V1 applies the same write-confirmation policy to both hosts.
+ V2 makes the policy host-keyed.
+- *Conversation-context injection* and *local/remote bridge
+ helpers* (current branch → PR, changed files → PR comments).
+ Future enhancements; tracked as a follow-up.
+- *Artifact download* (=gh run download=). Writes files; needs
+ its own confirmation flow. Deferred to V2.
+
+* Verified gh CLI Contracts
+
+These were checked against =gh --version 2.92.0= help output before
+the spec was revised. Implementation can rely on them.
+
+** Host / repo selection per subcommand
+
+- =gh api= and =gh auth status=: use =--hostname HOST= (long form
+ only; no short form).
+- =gh pr {view,list,diff,checks}=, =gh issue {view,list}=,
+ =gh run {view,list}=, =gh release *=, =gh search *=: use
+ =--repo [HOST/]OWNER/REPO= (short =-R=).
+- =gh repo view= and similar root-level commands: infer from cwd
+ remote; no =--hostname= or =--repo= flag.
+- Environment variables: =GH_HOST= and =GH_REPO= work for commands
+ that lack explicit flags.
+
+** Flags with overloaded short forms
+
+- =gh pr list -H FOO= → =--head FOO= (branch).
+- =gh search prs -H FOO= → =--head FOO= (branch).
+- =gh api -H KEY:VAL= → =--header KEY:VAL=.
+
+Substring scanning =-H= as "hostname" is wrong on every command
+that uses it.
+
+** =gh api= method classification
+
+The help text explicitly says: "adding request parameters will
+automatically switch the method to POST". This means:
+
+- =gh api repos/x/y/issues= → GET (read).
+- =gh api repos/x/y/issues -f title=Foo= → POST (write).
+- =gh api repos/x/y/issues -F body=@file= → POST (write +
+ exfiltration).
+- =gh api --method GET repos/x/y/issues -f q=x= → GET (read,
+ explicit override).
+
+Classifier must inspect for =-f=, =-F=, =--field=, =--raw-field=,
+=--input=, and =--method= before defaulting to GET.
+
+** Wrappers without =--json= support
+
+- =gh pr diff= produces patch output; no =--json= flag. Wrapper
+ is text-only. Structured file metadata available via
+ =gh pr view --json files= (new wrapper =gh_pr_files=).
+
+** Environment variables for non-interactive use
+
+All confirmed via =gh help environment=:
+
+- =GH_PROMPT_DISABLED= -- disable interactive prompting.
+- =GH_PAGER= / =PAGER= -- set to =cat= so no pager fires.
+- =GH_NO_UPDATE_NOTIFIER= -- silence the "new version" banner.
+- =GH_NO_EXTENSION_UPDATE_NOTIFIER= -- same for extensions.
+- =GH_SPINNER_DISABLED= -- silence the progress spinner.
+- =NO_COLOR= -- disable ANSI color (cleaner LLM context).
+
+V1 subprocess env always includes all of the above.
+
+** Authentication
+
+#+begin_example
+✓ Logged in to github.com account cjennings (keyring)
+✓ Logged in to deepsat.ghe.com account craig-jennings (keyring)
+#+end_example
+
+Both stored in the system keyring. =gh= reads the keyring per
+call; the wrapper does not handle tokens. Expired auth surfaces
+as exit code 4 with a clear stderr message; recovery is interactive
+(=gh auth login --hostname HOST= in a terminal).
+
+* Current State
+
+** =modules/ai-config.el=
+
+- =gptel-confirm-tool-calls nil= at line 386. Per-tool
+ =:confirm t= is the only confirmation mechanism in V1.
+- =cj/gptel-load-local-tools= (lines 71-96) loads tools from
+ =gptel-tools/=. Add new gh feature names to
+ =cj/gptel-local-tool-features=.
+
+** =gptel-tools/=
+
+Ten tools today. =git_log.el= is the closest analogue: validates
+input, runs subprocess with =process-file=, caps output, signals
+clear errors. The gh wrappers follow the same shape with shared
+helpers in =gh-common.el=.
+
+* Design
+
+** File layout
+
+Two files plus per-wrapper registrations. Wrappers are
+deliberately small (argv builders + JSON-field defaults) so they
+all live in one file:
+
+- =gptel-tools/gh-common.el= -- shared helpers: host/repo context,
+ argv runner, subprocess env, classifier, blocklist, redaction,
+ debug record, last-error buffer. No tool registrations.
+- =gptel-tools/gh.el= -- the general tool + every wrapper
+ registration. Pulls in =gh-common= via =require=.
+
+This matches Craig's preference for low loader surface (per the
+MCP revision discussion). If =gh.el= grows past ~600 lines we
+split per-resource later (=gh-pr.el=, =gh-issue.el=, etc.).
+
+** Host / repo resolution
+
+A single helper returns a structured context object used by every
+tool entry point:
+
+#+begin_src emacs-lisp
+(defun cj/gh--resolve-context (cwd hostname repo)
+ "Return a context plist for a gh invocation.
+Resolution order:
+1. Explicit REPO argument (`[HOST/]OWNER/REPO'). If it contains
+ `HOST/', split off the host.
+2. Explicit HOSTNAME argument.
+3. CWD's branch upstream remote URL (`git rev-parse --abbrev-ref
+ @{u}' then `git remote get-url REMOTE').
+4. CWD's `origin' remote URL.
+5. `cj/gh-default-host'.
+
+Returns:
+ (:host HOST :owner OWNER :repo REPO :source SYM)
+where SOURCE is one of: explicit-repo, explicit-host, upstream,
+origin, default."
+ ...)
+#+end_src
+
+URL parsing handles all three forms produced by git:
+
+- =git@HOST:OWNER/REPO.git=
+- =ssh://git@HOST/OWNER/REPO.git=
+- =https://HOST/OWNER/REPO.git=
+
+Returns =nil host= if the URL doesn't match a known host; the
+caller uses =cj/gh-default-host= in that case.
+
+#+begin_src emacs-lisp
+(defcustom cj/gh-default-host "github.com"
+ "Host used when no other resolution path yields one."
+ :type 'string
+ :group 'cj)
+
+(defcustom cj/gh-known-hosts '("github.com" "deepsat.ghe.com")
+ "Hosts the gh CLI is authenticated against."
+ :type '(repeat string)
+ :group 'cj)
+#+end_src
+
+Context appears in every tool response as a one-line prefix:
+
+#+begin_example
+[gh github.com/cjennings/dotemacs read ok 12.4KB] ...
+[gh deepsat.ghe.com/org/repo write confirmed 0.8KB] ...
+[gh github.com api unknown blocked] ...
+#+end_example
+
+This makes cross-host accidents visible.
+
+** Argv-list builders, never command strings
+
+Every tool builds an explicit argv list and hands it to a shared
+runner. Wrappers expose typed parameters that map directly into
+argv positions:
+
+#+begin_src emacs-lisp
+(defun cj/gh-pr-view--build-argv (context number include-comments fields)
+ "Return an argv list for `gh pr view NUMBER ...'."
+ (let ((repo-arg (format "%s/%s/%s"
+ (plist-get context :host)
+ (plist-get context :owner)
+ (plist-get context :repo))))
+ (append (list "pr" "view" (number-to-string number)
+ "--repo" repo-arg)
+ (when include-comments '("--comments"))
+ (when fields (list "--json" fields)))))
+#+end_src
+
+The general tool takes an =args= parameter typed as array-of-string
+(not a shell string) so the agent constructs argv directly:
+
+#+begin_src emacs-lisp
+;; agent passes: ["pr" "view" "171" "--repo" "deepsat.ghe.com/org/repo"]
+;; runner runs: gh pr view 171 --repo deepsat.ghe.com/org/repo
+#+end_src
+
+If the agent accidentally includes leading =gh= in args[0], the
+runner strips it and logs the normalization.
+
+** Subprocess contract
+
+One runner for everything:
+
+#+begin_src emacs-lisp
+(defun cj/gh--run (argv &key timeout context)
+ "Run gh with ARGV (a list of strings).
+Returns a plist:
+ (:exit-code N :stdout STR :stderr STR :truncated BOOL :duration-ms N
+ :argv ARGV :context CONTEXT)
+Enforces TIMEOUT (default `cj/gh--default-timeout').
+Captures output in-flight, killing the process at
+`cj/gh--max-bytes' to prevent runaway memory use."
+ ...)
+#+end_src
+
+Contract:
+
+- *Env vars set every call:* =GH_PROMPT_DISABLED=1=, =GH_PAGER=cat=,
+ =PAGER=cat=, =NO_COLOR=1=, =GH_NO_UPDATE_NOTIFIER=1=,
+ =GH_NO_EXTENSION_UPDATE_NOTIFIER=1=, =GH_SPINNER_DISABLED=1=.
+- *Timeout:* default 20 s for reads, 60 s for diffs/logs/search.
+ Per-tool override allowed but capped at 120 s. Timeout kills
+ the process and returns =:error-kind 'timeout=.
+- *Output cap during capture:* a process filter accumulates bytes
+ up to =cj/gh--max-bytes= (64 KB). At the cap, the filter sets
+ =truncated=, ignores further output, and sends SIGTERM after a
+ short grace. Truncation marker appended to returned stdout.
+- *TRAMP rejection:* if =(file-remote-p default-directory)= is
+ non-nil, return =:error-kind 'remote-cwd= without invoking gh.
+- *Executable check:* if =(executable-find cj/gh--executable)= is
+ nil, return =:error-kind 'no-executable= with install
+ instructions.
+- *Version check:* the first call per session verifies
+ =gh --version= meets =cj/gh--min-version= (default "2.50.0");
+ cached for the session.
+
+** Read / write / destructive classifier
+
+Used to apply =:confirm t= and the irreversible blocklist:
+
+#+begin_src emacs-lisp
+(defconst cj/gh--read-verbs
+ '("view" "list" "status" "search" "diff" "checks" "describe"
+ "show" "logs" "auth-status"))
+
+(defconst cj/gh--write-verbs
+ '("create" "edit" "merge" "close" "reopen" "comment" "review"
+ "upload" "set" "add" "remove" "rerun" "cancel" "delete"
+ "fork" "archive" "unarchive" "lock" "unlock" "pin" "unpin"
+ "ready" "draft" "rename" "transfer" "approve" "label" "assign"))
+
+(defconst cj/gh--blocked-verbs
+ '(;; interactive / opens UI
+ "login" "logout" "checkout" "clone" "ssh" "code" "edit-prompt"
+ ;; modifies user config
+ "alias" "config" "extension"
+ ;; opens browser
+ "browse"))
+
+(defconst cj/gh--blocked-flags
+ '("--web" ; many commands
+ "--paginate" ; can produce unbounded output
+ "--input" ; gh api file upload
+ "--editor")) ; opens editor
+
+(defconst cj/gh--irreversible-patterns
+ '("\\`repo delete\\b"
+ "\\`release delete\\b"
+ "\\`secret delete\\b"
+ "\\`ssh-key delete\\b"
+ "\\`gpg-key delete\\b"
+ "\\`org delete\\b"
+ "\\`project delete\\b"
+ "\\`variable delete\\b"
+ "\\`ruleset delete\\b"
+ "\\`label delete\\b.*--yes"))
+#+end_src
+
+Classifier:
+
+#+begin_src emacs-lisp
+(defun cj/gh--classify (argv)
+ "Return one of: read, write, destructive, blocked, unknown."
+ (let* ((stripped (cj/gh--strip-flags argv))
+ (resource (car stripped))
+ (verb (cadr stripped)))
+ (cond
+ ;; Hard blocks first.
+ ((cj/gh--has-blocked-verb-p argv) 'blocked)
+ ((cj/gh--has-blocked-flag-p argv) 'blocked)
+ ((cj/gh--has-file-arg-p argv) 'blocked) ; -F key=@file
+ ((cj/gh--matches-irreversible-p argv) 'destructive)
+ ;; gh api is special.
+ ((string= resource "api") (cj/gh--classify-api argv))
+ ;; Verb match.
+ ((member verb cj/gh--read-verbs) 'read)
+ ((member verb cj/gh--write-verbs) 'write)
+ (t 'unknown))))
+
+(defun cj/gh--classify-api (argv)
+ "Classify a `gh api ...' invocation.
+Reads: explicit method GET/HEAD with no -f/-F/--input.
+Writes: any -f/-F/--field/--raw-field/--input, OR explicit
+non-GET/HEAD method.
+Default (no method, no field): read (matches gh's GET default)."
+ (let* ((explicit-method (or (cadr (member "-X" argv))
+ (cadr (member "--method" argv))))
+ (has-field (cl-some
+ (lambda (f) (cl-some (lambda (a) (string-prefix-p f a))
+ argv))
+ '("-f" "--raw-field" "-F" "--field")))
+ (has-input (member "--input" argv)))
+ (cond
+ (has-input 'blocked) ; file exfiltration
+ ((and explicit-method
+ (not (member (upcase explicit-method) '("GET" "HEAD"))))
+ 'write)
+ (has-field 'write) ; -f/-F auto-promotes to POST
+ ((and explicit-method
+ (member (upcase explicit-method) '("GET" "HEAD")))
+ 'read)
+ (t 'read))))
+#+end_src
+
+Classifier-driven policy table:
+
+| Classification | Policy |
+|----------------+--------|
+| =read= | auto-execute |
+| =write= | =:confirm t= on registration; agent's call shows in confirm prompt |
+| =destructive= | hard-block; return =:error-kind 'irreversible-blocked= |
+| =blocked= | hard-block; return =:error-kind 'policy-blocked= with reason |
+| =unknown= | =:confirm t= (fail closed) |
+
+** Safety policy
+
+V1 uniform policy applied to both hosts:
+
+#+begin_src emacs-lisp
+(defcustom cj/gh-policy
+ '((read . auto)
+ (write . confirm)
+ (destructive . block)
+ (blocked . block)
+ (unknown . confirm))
+ "Per-classification policy. V1 applies uniformly to both hosts."
+ :type '(alist :key-type symbol :value-type symbol)
+ :group 'cj)
+#+end_src
+
+Since GPTel's =:confirm t= flag is per-tool-registration (not
+per-call), the general tool is registered with =:confirm t=
+always. The wrappers are registered per their classification:
+
+| Tool | Registered with |
+|------+-----------------|
+| Read wrappers (=gh_pr_view=, etc.) | =:confirm nil= |
+| =gh= general tool | =:confirm t= |
+
+The general tool then applies the policy table at invocation
+time: reads execute without further prompting (already past
+GPTel's confirm because :confirm t fires once); writes show
+detail before invocation; destructive/blocked never reach gh.
+
+** General tool: =gh=
+
+Single tool registered with =:confirm t= covering everything the
+wrappers don't. Schema:
+
+| Arg | Type | Required | Purpose |
+|-----+------+----------+---------|
+| =args= | array of string | yes | argv list, e.g. =["pr", "view", "171", "--repo", "deepsat.ghe.com/org/repo"]= |
+| =hostname= | string | no | Override host for commands that accept =--hostname= (=api=, =auth status=); ignored otherwise |
+| =repo= | string | no | =[HOST/]OWNER/REPO= for repo-scoped commands; if HOST is present in repo, hostname arg is overridden |
+| =cwd= | string | no | Working directory; defaults to current buffer; must be under =$HOME=, must not be TRAMP |
+| =timeout= | integer | no | Seconds before kill; default 20, max 120 |
+
+Description (registered) explicitly says:
+
+#+begin_example
+Use this only when no task-specific gh_* tool fits. Prefer
+gh_pr_view, gh_pr_list, gh_pr_checks, gh_issue_view, gh_issue_list,
+gh_run_view, gh_run_list, gh_run_logs_failed, gh_repo_view,
+gh_search_prs, gh_search_issues, gh_api_get.
+
+Writes (create/edit/merge/etc.) require user confirmation.
+Destructive (repo/release/secret delete) are hard-blocked.
+Interactive commands (auth login, codespace ssh, --web, browse)
+are hard-blocked. File uploads (api --input, -F @file, release
+upload, gist create) are hard-blocked.
+#+end_example
+
+** Wrapper inventory
+
+Twelve wrappers grouped by resource. Each has typed args, JSON
+field defaults, output truncation, timeout override, and a
+description that names its scope.
+
+| Tool | gh command | Defaults | Timeout |
+|------+------------+----------+---------|
+| =gh_repo_view= | =repo view --json= | JSON fields: =name,nameWithOwner,description,defaultBranchRef,url,visibility= | 20s |
+| =gh_pr_view= | =pr view N --json= | JSON fields: =number,title,state,author,createdAt,url,body= (body truncated) | 20s |
+| =gh_pr_list= | =pr list --json= | JSON fields: =number,title,state,author,createdAt,headRefName=; default =--limit 30= (capped at 100) | 20s |
+| =gh_pr_diff= | =pr diff N --color never= | text only; capped at 64 KB | 60s |
+| =gh_pr_checks= | =pr checks N --json= | JSON fields: =name,status,conclusion,startedAt,completedAt,link= | 20s |
+| =gh_pr_files= | =pr view N --json files= | JSON fields: =files= (path, additions, deletions, mode) | 20s |
+| =gh_pr_current= | =pr view --json= (no number — auto-detect) | Same as =gh_pr_view= | 20s |
+| =gh_issue_view= | =issue view N --json= | JSON fields: =number,title,state,author,createdAt,url,body= (body truncated) | 20s |
+| =gh_issue_list= | =issue list --json= | JSON fields: =number,title,state,author,createdAt,labels=; default =--limit 30= | 20s |
+| =gh_run_view= | =run view RUN-ID --json= | JSON fields: =databaseId,name,status,conclusion,startedAt,headBranch,event,url= | 20s |
+| =gh_run_list= | =run list --json= | JSON fields: =databaseId,name,status,conclusion,startedAt,headBranch=; default =--limit 20= | 20s |
+| =gh_run_logs_failed= | =run view RUN-ID --log-failed= | text only; capped at 64 KB | 60s |
+| =gh_search_prs= | =search prs --json= | JSON fields: =number,title,state,author,repository,url=; default =--limit 30= (capped at 100) | 30s |
+| =gh_search_issues= | =search issues --json= | Same as PR search | 30s |
+| =gh_api_get= | =api ENDPOINT --method GET= | text/JSON pass-through; rejects fields/input args | 30s |
+
+Common args for every wrapper (unless noted):
+
+| Arg | Type | Required | Purpose |
+|-----+------+----------+---------|
+| =repo= | string | no | =[HOST/]OWNER/REPO=; resolved from context otherwise |
+| =hostname= | string | no | Override host for context resolution |
+| =limit= | integer | no | =--limit= for list/search wrappers; clamped to per-wrapper max |
+
+The =gh_api_get= wrapper *explicitly* rejects =-f=, =-F=,
+=--field=, =--raw-field=, =--input=, =--method=, and any method
+override. It only accepts =ENDPOINT= and optional =-H= headers
+that don't carry secrets (the runner redacts =Authorization:= and
+similar regardless). Writes via API go through the general tool
+with confirmation.
+
+** JSON field defaults
+
+Per-wrapper defaults are minimal -- enough for the agent to decide
+whether to drill in, not so much that one call fills the context.
+
+For =gh_pr_view= specifically:
+
+- *Default* (no =fields= override): the small list above (number,
+ title, state, author, createdAt, url, body-truncated-to-2KB).
+- *Override*: agent passes =fields= as a comma-separated string;
+ wrapper validates against a per-resource allowlist (so the agent
+ can't request =reviews,comments,files= in one call to bypass the
+ cap).
+- *Include flags*: =include-body t/nil=, =include-comments t/nil=,
+ =include-reviews t/nil= as boolean args. Each adds the
+ corresponding JSON field; agent opts in only when needed.
+
+** Output truncation
+
+Process filter pattern, not post-hoc cap:
+
+#+begin_src emacs-lisp
+(defun cj/gh--make-filter (state-var)
+ "Return a process filter that accumulates into STATE-VAR's :stdout,
+stops collecting after `cj/gh--max-bytes', sets :truncated, and
+sends SIGTERM to the process."
+ (lambda (proc output)
+ (let* ((state (symbol-value state-var))
+ (current (plist-get state :stdout))
+ (current-len (length current))
+ (remaining (- cj/gh--max-bytes current-len)))
+ (cond
+ ((<= remaining 0) nil) ; already at cap
+ ((<= (length output) remaining)
+ (plist-put state :stdout (concat current output)))
+ (t
+ (plist-put state :stdout
+ (concat current (substring output 0 remaining)))
+ (plist-put state :truncated t)
+ (ignore-errors (delete-process proc)))))))
+#+end_src
+
+Truncation marker appended before return:
+
+#+begin_example
+[truncated at 64KB; use --limit, narrower fields, or a specific
+wrapper to reduce output]
+#+end_example
+
+** Error classification + debug record
+
+Every call returns (and =cj/gh-tool-last-error= caches) a debug
+record:
+
+#+begin_src emacs-lisp
+(defun cj/gh--debug-record (argv context exit-code stdout stderr
+ duration-ms truncated)
+ (list :host (plist-get context :host)
+ :repo (cj/gh--repo-arg context)
+ :cwd default-directory
+ :argv (cj/gh--redact-argv argv)
+ :classification (cj/gh--classify argv)
+ :policy (cj/gh--policy-decision argv)
+ :exit-code exit-code
+ :duration-ms duration-ms
+ :bytes-captured (length stdout)
+ :truncated truncated
+ :error-kind (cj/gh--error-kind exit-code stderr)))
+#+end_src
+
+=:error-kind= mapping:
+
+| Condition | =:error-kind= | Returned message |
+|-----------+---------------+------------------|
+| Exit 4 + "authentication required" | =auth= | "Run =gh auth login --hostname HOST= in a terminal." |
+| Process killed by timeout timer | =timeout= | "Command exceeded N seconds; narrow the query or use a more specific wrapper." |
+| Policy block | =policy-blocked= | "Blocked by V1 policy: REASON." |
+| Irreversible match | =irreversible-blocked= | "Hard-blocked irreversible command: COMMAND." |
+| Truncated output | =truncated= | "Output truncated at 64KB; reduce scope." |
+| TRAMP cwd | =remote-cwd= | "Cannot run gh from remote directory: CWD." |
+| Missing executable | =no-executable= | "gh not found at CJ/GH--EXECUTABLE; install via 'pacman -S github-cli' (or equivalent)." |
+| Other non-zero exit | =gh-exit= | (raw stderr, redacted) |
+
+Each error includes a sanitized reproduce line:
+
+#+begin_example
+Reproduce: GH_PROMPT_DISABLED=1 GH_PAGER=cat gh pr view 171 --repo HOST/OWNER/REPO
+#+end_example
+
+(Secrets, body text, file paths redacted via =cj/gh--redact-argv=.)
+
+** Audit log (V1, opt-out)
+
+Every call appends one line to
+=~/.emacs.d/data/gh-tool-log/YYYY-MM-DD.log=:
+
+#+begin_example
+2026-05-16T14:23:45-0500 host=github.com repo=cjennings/dotemacs class=read policy=auto exit=0 duration=128ms bytes=4321
+2026-05-16T14:24:02-0500 host=deepsat.ghe.com repo=org/repo class=write policy=confirm exit=0 duration=412ms bytes=88
+#+end_example
+
+Metadata only, not output bodies. Defcustom
+=cj/gh-tool-audit-log-enabled= (default =t=). Daily rotation
+implicit (one file per day). Cleanup manual.
+
+** Secrets redaction
+
+=cj/gh--redact-argv= masks:
+
+- Anything after =--token=, =--secret=, =--password= flags.
+- Authorization headers (=-H "Authorization: ..."=).
+- =--figma-api-key=KEY= (in case general tool spawns figma-mcp
+ somehow).
+- Bearer tokens in URLs (=?token=...=).
+- Values for =-f=/=-F= keys named like =body=, =text=,
+ =description= (private content; metadata still logged).
+
+Applied to:
+- All stderr returned to the agent.
+- All audit-log lines.
+- All debug records.
+- The reproduce line on error.
+
+* Commands & UX
+
+** =cj/gh-doctor=
+
+Diagnostic command. No side effects. Checks:
+
+- =gh= executable found at =cj/gh--executable=.
+- =gh --version= meets =cj/gh--min-version=.
+- =gh auth status= for each host in =cj/gh-known-hosts=.
+- Current buffer's cwd repo detection: resolves to which host/repo?
+- Environment overrides effective (=GH_PROMPT_DISABLED= etc. would
+ be set by the runner).
+- Active account per host.
+- Warnings if any block-list-relevant env is set externally
+ (e.g. user already has =GH_PAGER= set to something that pages).
+
+Output: a buffer with PASS / FAIL / WARN per check + recovery
+actions for failures.
+
+** =cj/gh-tool-last-error=
+
+Opens a buffer showing the last call's debug record:
+
+#+begin_example
+Host: deepsat.ghe.com
+Repo: org/repo
+Source: upstream
+CWD: ~/projects/work/foo
+Argv: ("pr" "view" "171" "--repo" "deepsat.ghe.com/org/repo")
+Classification: read
+Policy: auto
+Exit code: 0
+Duration: 412 ms
+Bytes captured: 4321
+Truncated: no
+Error kind: none
+
+Reproduce:
+ GH_PROMPT_DISABLED=1 GH_PAGER=cat NO_COLOR=1 \
+ gh pr view 171 --repo deepsat.ghe.com/org/repo
+#+end_example
+
+** Tool response header
+
+Every tool result begins with a one-line header so cross-host /
+policy decisions are visible:
+
+#+begin_example
+[gh github.com/cjennings/dotemacs read ok 4.3KB]
+{ ... }
+[gh deepsat.ghe.com/org/repo write confirmed 0.2KB]
+{ ... }
+[gh github.com/cjennings/dotemacs api blocked policy-blocked]
+Error: Hard-blocked file-upload path: --input file.
+Reproduce: gh api repos/cjennings/dotemacs/contents/foo --input file
+#+end_example
+
+* Implementation Plan
+
+Eight phases. Each ends with green ERT tests + manual smoke
+before the next.
+
+** Phase 1 -- Common helpers + context resolver
+
+=gh-common.el=: =cj/gh--executable=, =cj/gh--available-p=,
+=cj/gh--version= (cached), =cj/gh--validate-cwd= (HOME + non-TRAMP),
+=cj/gh--parse-remote-url=, =cj/gh--resolve-context=,
+=cj/gh--redact-argv=. No subprocess execution yet.
+
+Tests cover all helpers against fixture remote URLs and synthetic
+git directories. No real gh calls.
+
+** Phase 2 -- Runner with subprocess env + timeout + in-flight cap
+
+=cj/gh--run=, the process filter, the timer kill path, env-var
+setup. Tests stub =make-process= to simulate output / exit / hang
+/ truncation paths.
+
+Acceptance: with stub configured to produce 100 KB of output,
+returned stdout is exactly 64 KB plus the truncation marker, and
+the process gets SIGTERM.
+
+** Phase 3 -- Classifier + blocklist + policy
+
+=cj/gh--classify=, =cj/gh--classify-api=, blocklist constants,
+=cj/gh--policy-decision=. Tests for every blocklist pattern
+(verbs + flags + file-arg paths), API edge cases (=-f= promotes
+to POST, =--method GET -f x=y= stays GET, etc.), and the
+unknown-fails-closed contract.
+
+** Phase 4 -- Read wrappers (5 first)
+
+=gh_repo_view=, =gh_pr_view=, =gh_pr_list=, =gh_pr_diff=,
+=gh_issue_view=. Each is a thin schema + argv builder + delegate
+to =cj/gh--run=. Tests verify argv shape for typical args.
+
+Manual smoke against both hosts. First real gh calls.
+
+** Phase 5 -- Remaining wrappers + JSON defaults
+
+Eight more wrappers (=gh_pr_checks=, =gh_pr_files=,
+=gh_pr_current=, =gh_issue_list=, =gh_run_view=, =gh_run_list=,
+=gh_run_logs_failed=, =gh_search_prs=, =gh_search_issues=,
+=gh_api_get=). JSON-field defaults per wrapper. Tests for the
+=gh_api_get= flag-rejection contract.
+
+** Phase 6 -- General tool
+
+=gh.el= general tool registration with =:confirm t=, blocklist
+enforcement, policy decision applied before invocation. Tests
+verify confirmation gate (stub gptel's confirm flow), blocked
+commands never reach =cj/gh--run=, destructive commands return
+=:irreversible-blocked= without prompting.
+
+** Phase 7 -- UX: doctor, last-error, response header
+
+=cj/gh-doctor=, =cj/gh-tool-last-error=, response header
+formatting. Audit log writer. Defcustom for log enable.
+
+** Phase 8 -- Loader wiring + integration
+
+Add the 16 feature names to =cj/gptel-local-tool-features= (one
+per wrapper + the general tool + the helpers feature). Verify
+they land in =gptel-tools=.
+
+* Test Plan
+
+Target: 55-70 ERT tests across four files. No real subprocesses,
+no real network, no real =~/.claude.json= (gh tools don't use it,
+but the no-real-process rule applies uniformly).
+
+** =tests/test-gh-common.el= -- pure helpers (~25 tests)
+
+- =cj/gh--parse-remote-url=: ssh-scp, ssh-url, https, with/without
+ =.git=, with/without trailing slash, unknown host returns =:host
+ nil=, =github.com.evil.example= does NOT match =github.com=.
+- =cj/gh--resolve-context=: explicit repo wins; explicit hostname
+ wins over remote; upstream beats origin; origin beats default;
+ default fires when no git.
+- =cj/gh--validate-cwd=: HOME-rooted ok; outside HOME errors;
+ TRAMP errors; non-directory errors.
+- =cj/gh--redact-argv=: =--token=, =-H "Authorization: ..."=,
+ =--figma-api-key=, =-f body=...= → body redacted; sentinel
+ =REDACTED_TEST_SECRET= never appears in any output of any
+ helper.
+- =cj/gh--available-p=: nil when =executable-find= fails; t
+ otherwise.
+- =cj/gh--version=: caches per session; floor check rejects
+ too-old.
+
+** =tests/test-gh-runner.el= -- runner contract (~15 tests)
+
+Stub =make-process=:
+- Normal exit 0 with short output: returned verbatim, no
+ truncation flag.
+- Long output (100 KB stub): truncated at 64 KB exactly,
+ truncation marker present, =:truncated t=.
+- Process hangs past timeout: timer fires, SIGTERM sent, returns
+ =:error-kind 'timeout=.
+- Exit 4 + auth stderr: returns =:error-kind 'auth= with recovery
+ message.
+- TRAMP cwd: never invokes =make-process=, returns =:error-kind
+ 'remote-cwd=.
+- Missing executable: returns =:error-kind 'no-executable=.
+- Environment: =process-environment= includes all six required
+ vars before =make-process= call.
+- Argv with leading "gh" stripped + logged.
+
+** =tests/test-gh-classifier.el= -- policy logic (~20 tests)
+
+- Every read verb classifies as read.
+- Every write verb classifies as write.
+- Every destructive pattern matches.
+- Every blocked verb (=login=, =browse=, =clone=, etc.) classifies
+ as blocked.
+- Every blocked flag (=--web=, =--paginate=, =--input=) classifies
+ as blocked.
+- File-upload (=-F key=@/path=) classifies as blocked.
+- =gh api= GET (no fields): read.
+- =gh api= GET (=-f x=y=): write (auto-POST per gh's rules).
+- =gh api --method GET -f q=x=: read (explicit override).
+- =gh api -X DELETE=: destructive.
+- =gh api -X PATCH=: write.
+- =gh api --input file=: blocked.
+- Unknown verb (=gh frobnicate=): unknown → confirm.
+- =-H= as branch (=gh pr list -H feature=) doesn't trigger host
+ treatment.
+- =-H= as header (=gh api -H Accept:json=) doesn't trigger host
+ treatment.
+- Policy decision: read → auto; write → confirm; destructive →
+ block; blocked → block; unknown → confirm.
+
+** =tests/test-gh-wrappers.el= -- per-wrapper builders + schemas (~15 tests)
+
+- Every wrapper's schema is valid (correct =:name=, =:type=,
+ =:description=, =:args= shape).
+- =gh_pr_view= argv with number 171 and repo "host/o/r" produces
+ =("pr" "view" "171" "--repo" "host/o/r" "--json" "DEFAULTS")=.
+- =gh_pr_diff= rejects =format='json=.
+- =gh_api_get= rejects =-f=, =-F=, =--field=, =--raw-field=,
+ =--input=, =--method= other than GET.
+- =gh_pr_current= invokes without a number arg (uses cwd).
+- =limit= clamped to per-wrapper max.
+- =fields= validated against per-resource allowlist.
+
+** Manual smoke (every phase)
+
+| Phase | Smoke |
+|-------+-------|
+| 4 | =gh_pr_view N= against both hosts |
+| 4 | =gh_pr_list= in =~/.emacs.d= → uses github.com/cjennings/dotemacs |
+| 5 | =gh_pr_checks= shows CI status without full logs |
+| 5 | =gh_run_logs_failed= cap kicks in on a long-failed run |
+| 5 | =gh_api_get= rejects a =-f= arg with clear error |
+| 6 | =gh= general tool: agent asked to merge a PR triggers GPTel confirm prompt |
+| 6 | Agent asked to =gh repo delete= gets irreversible-blocked |
+| 6 | Agent asked to =gh --web=... gets policy-blocked |
+| 7 | =cj/gh-doctor= correctly identifies an unauthenticated host |
+| 7 | =cj/gh-tool-last-error= shows debug record after a failing call |
+
+** Opt-in integration suite
+
+A small set of real-gh tests (in =tests/test-gh-integration.el=
+marked =:tag :integration=, default skipped):
+
+- =gh auth status --hostname github.com= ok.
+- =gh auth status --hostname deepsat.ghe.com= ok.
+- =gh repo view cjennings/dotemacs --json name=
+ returns parseable JSON.
+- =gh pr list --repo cjennings/dotemacs --limit 1= returns ≤ 1
+ PR.
+
+Run manually via =make test-name TEST=gh-integration=.
+
+* Acceptance Criteria
+
+1. *Argv contract.* No tool produces a command string for execution;
+ every call goes through the argv-list runner.
+2. *No silent writes.* Every classified write either prompts
+ GPTel's confirm or hard-blocks. Verified by an end-to-end
+ test where the agent attempts =pr merge= and the test fails if
+ =make-process= is invoked before confirm.
+3. *In-flight cap.* A stubbed process emitting 1 MB returns
+ exactly 64 KB; the runner never holds more than 65 KB in
+ memory.
+4. *Host visibility.* Every successful tool response begins with
+ =[gh HOST/REPO ...]=. Verified by a test that greps the
+ response text.
+5. *Doctor coverage.* =cj/gh-doctor= correctly identifies (a) no
+ gh executable, (b) too-old gh, (c) unauthenticated host, (d)
+ non-git cwd, (e) git cwd whose remote points to an unknown
+ host.
+6. *No secret leakage.* Test fixtures containing
+ =REDACTED_TEST_SECRET= in every secret-bearing slot
+ (=--token=, =-H Authorization=, =-f body=, etc.) produce zero
+ matches when grepping audit log, debug record, and any
+ user-facing message.
+
+* Risks
+
+** R1 -- gh CLI evolves and verbs drift
+
+New =gh= versions may add subcommands the blocklist doesn't
+cover. Or rename verbs.
+
+*Mitigation:* the blocklist works on verbs (not full subcommand
+paths) so most additions are caught. Doctor includes a
+=gh --version= floor. Periodic review when gh bumps a major
+version.
+
+** R2 -- The "all reads auto-execute" default may still be too broad
+
+Some reads expose private content (issue bodies, PR descriptions
+from private repos). An agent surfacing a confidential issue
+body into a saved conversation has data-leak implications.
+
+*Mitigation:* response header makes the host/repo visible in
+every result, so the saved conversation makes the privacy
+boundary auditable. Wrappers truncate body/comments by default;
+agent must explicitly opt-in to include them. Documented in
+commentary.
+
+** R3 -- The general tool's =:confirm t= prompt may become click-fatigue
+
+If the agent uses the general tool heavily during a write-heavy
+workflow (PR creation, label management), confirming every call
+becomes tedious.
+
+*Mitigation:* the expanded wrapper set covers most reads, so the
+general tool fires mainly for writes -- where confirmation is
+exactly the right behavior. If usage shows confirm fatigue,
+V2's per-host policy can add =:auto= for explicit
+write-confirmed contexts.
+
+** R4 -- =--paginate= block conflicts with legitimate large queries
+
+Blocking =--paginate= globally means the agent can't get
+historical CI runs (which may need pagination).
+
+*Mitigation:* =gh_run_list= and =gh_search_*= accept a clamped
+=--limit= which usually substitutes. If a use case needs more,
+the agent can request multiple non-paginated pages explicitly.
+
+** R5 -- Token expiry surfaces as cryptic exit 4
+
+When a host's keyring entry expires, every call returns exit 4
+with "authentication required" on stderr. The agent sees the
+error but may not realize the fix is interactive.
+
+*Mitigation:* the runner's =:error-kind 'auth= mapping prepends
+the recovery message before returning to the agent. =cj/gh-doctor=
+proactively checks auth status.
+
+** R6 -- TRAMP cwd silently runs gh remotely
+
+Without the explicit TRAMP rejection, =process-file= would try to
+spawn =gh= on the remote host (where it may not exist, or may
+authenticate against the wrong keyring).
+
+*Mitigation:* runner checks =(file-remote-p default-directory)=
+first and returns =:error-kind 'remote-cwd= with a clear message.
+
+** R7 -- =gh search= behaves differently on GHE
+
+GHE may not support every advanced search operator
+=github.com= does. Search wrappers may return inconsistent
+results across hosts.
+
+*Mitigation:* documented in =gh_search_*= wrapper descriptions.
+Result header makes the host visible so the agent can adjust.
+
+** R8 -- Audit log grows unbounded
+
+One file per day, but no automatic cleanup.
+
+*Mitigation:* metadata-only entries are tiny (~150 bytes); a
+year of heavy use is a few MB. Manual cleanup acceptable.
+Defcustom to disable for users who don't want it.
+
+* Open Questions
+
+** Q1 -- Should the general tool's confirmation prompt include the classification?
+
+When GPTel asks "Run gh tool? (y/n)" the prompt shows the argv but
+not the classification. Showing "WRITE: gh pr merge 171" gives the
+user more context. Need to investigate gptel's confirm-prompt
+extensibility.
+
+** Q2 -- Should =gh_pr_diff= cap differently from text wrappers?
+
+A PR diff can legitimately be 100KB+ for a large refactor. The
+64KB cap is the same as everywhere else. If diffs need a higher
+cap (256KB?), that's per-wrapper config.
+
+** Q3 -- Should wrappers expose =include-body=, =include-comments=, etc., as separate args, or as a comma-separated list?
+
+The spec proposes separate boolean args (=:include-body t=,
+=:include-comments t=). Alternative: one =:include= comma-list
+arg. Separate args are more discoverable; comma-list is more
+compact. Decide during Phase 4.
+
+** Q4 -- Should =cj/gh-tool-audit-log= grow into a query interface?
+
+V1 writes one line per call. Future: a command to query the
+log (=cj/gh-audit-search REGEX=) for surfacing "what did the agent
+do to this PR last week?".
+
+* V2 Roadmap
+
+Items intentionally deferred:
+
+- *Per-host policy.* =cj/gh-host-policy= alist keyed by hostname
+ (mirror of the MCP spec's structure) so work GHE can be
+ read-only while personal allows writes-with-confirm.
+- *Conversation context injection.* After a PR view, the wrapper
+ inserts a "GitHub context: HOST/REPO PR #N at URL" line into the
+ GPTel buffer so saved conversations stay traceable without
+ bundling full output.
+- *Local/remote bridge helpers.* current-branch → PR-number,
+ changed-files → matching PR file comments, etc.
+- *Artifact download.* =gh_run_artifacts=, =gh_release_download=
+ with explicit confirm and write to a controlled directory.
+- *Async general tool.* =make-process= + sentinel for the cases
+ where 60s timeout isn't enough (rare, but real for some
+ =--paginate= scenarios).
+- *Audit log query interface.* =cj/gh-audit-search=,
+ =cj/gh-audit-by-host=.
+- *Profile-based tool subsets.* e.g. read-only profile
+ vs. write-capable profile per buffer.
+
+* References
+
+- [[file:../../gptel-tools/git_log.el][gptel-tools/git_log.el]] -- pattern reference for new tool files.
+- [[file:../../modules/ai-config.el][modules/ai-config.el]] -- =gptel-confirm-tool-calls nil= at
+ line 386; loader at lines 71-96.
+- [[file:gptel-agentic-tool-ideas.org][gptel-agentic-tool-ideas.org]] -- broader agentic-tool design;
+ =gh= sits alongside the MCP integration as the
+ collaboration tier.
+- [[id:b4c274c5-8572-4a7b-b657-d315712bd6af][mcp-el-gptel-integration-spec-doing.org]] -- sibling design; same
+ confirm-on-write pattern for safety.
+- [[https://cli.github.com/manual/][gh CLI manual]] -- subcommand reference.
+- =gh --version 2.92.0= help output -- verified flag semantics
+ per subcommand.
+- =gh help environment= -- verified env-var names for non-interactive
+ mode.
diff --git a/docs/specs/gptel-git-tools-magit-backend-spec.org b/docs/specs/gptel-git-tools-magit-backend-spec.org
new file mode 100644
index 000000000..bd84b0595
--- /dev/null
+++ b/docs/specs/gptel-git-tools-magit-backend-spec.org
@@ -0,0 +1,196 @@
+:PROPERTIES:
+:ID: bd47c9a8-aae1-4a3d-ad5b-b8767f2fd580
+:STATUS: not-started
+:END:
+#+TITLE: Design: gptel git tools on a magit backend
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+
+* Status
+
+Draft. Supersedes the three current git-tool implementations
+(=gptel-tools/git_status.el=, =gptel-tools/git_log.el=,
+=gptel-tools/git_diff.el=) shipped in commit =ceeae9b5=. Trigger:
+Craig flagged that magit already does much of this and could carry
+the backend for more git tools cheaply.
+
+* Problem
+
+The three current git_* tools shell out to git directly via
+=process-file= and parse stdout. Each carries:
+
+- Its own =--is-inside-work-tree= path-validation step.
+- Its own =-c color.ui=false= color suppression workaround (`git
+ status' doesn't accept =--no-color= the way `git log' / `git diff'
+ do).
+- Boilerplate to set up a temp buffer, run =process-file=, capture
+ output, return the string.
+
+There's also an opportunity cost: adding more git context tools
+(=git_blame=, =git_show=, =git_branches=, etc.) would mean
+duplicating the same boilerplate per tool.
+
+* Wins from a magit backend
+
+Three concrete things magit provides:
+
+1. *Path validation via =magit-toplevel=.* One call replaces the
+ two-step =process-file= + =rev-parse --is-inside-work-tree=
+ check. Returns the working-tree root or nil.
+
+2. *Process plumbing via =magit-git-insert= / =magit-git-string= /
+ =magit-git-lines=.* These wrap git invocation with magit's
+ environment, encoding handling, and the right color posture.
+ Drops the per-subcommand color-flag bikeshedding.
+
+3. *Typed helpers for higher-level concepts* -- =magit-get-current-branch=,
+ =magit-list-branches=, =magit-rev-ancestor-p=, etc. Most
+ relevant for the *new* tools (branches, show, blame), not the
+ three we already wrote.
+
+What magit doesn't give us: high-level "give me status as a string"
+helpers. =magit-status= / =magit-log-current= etc. populate
+interactive magit buffers, not strings. For tool output we'd still
+call =magit-git-insert "status" "--short" "--branch"= and grab the
+buffer string. Same shape, less boilerplate.
+
+* Costs
+
+- *Magit loads on first invocation* of any git_* tool. Magit pulls
+ in transient, with-editor, magit-section, magit-core -- heavyweight.
+ Mitigation: lazy =(require 'magit)= inside each tool's function
+ body so cold-start Emacs sessions don't pay the cost unless the
+ user actually calls a git tool.
+- *Tools no longer portable* to a no-magit Emacs. Acceptable here
+ because magit is a non-negotiable in this config; a future
+ drop-in distribution would need to publish a magit-free fallback.
+
+* Proposed shape
+
+** Single-file module: =gptel-tools/git_tools.el=
+
+The current "one file per tool" convention exists because the
+existing tools share little. These six tools share a lot
+(validate-path, run-git, truncate-output), so a single file with
+shared helpers is more honest.
+
+** Shared helpers
+
+- =cj/gptel-git--toplevel-or-error PATH=
+ - Wraps =magit-toplevel=. Signals =user-error= when PATH escapes
+ HOME, doesn't exist, or isn't inside a working tree.
+ - Returns the resolved working-tree root on success.
+
+- =cj/gptel-git--insert ARGS...=
+ - Wraps =magit-git-insert= in a =with-temp-buffer=, returns
+ =buffer-string=. Single chokepoint for color / encoding / error
+ handling.
+
+- =cj/gptel-git--truncate TEXT MAX-BYTES=
+ - Caps output, appends a one-line truncation marker when
+ triggered.
+
+ Open question: consolidate the matching helper from =web_fetch.el=
+ (=cj/gptel-web-fetch--truncate=) and the
+ =cj/update-text-file--*= analogue into a shared
+ =cj/gptel-tools--truncate-bytes= in =system-lib.el=, or keep
+ per-tool.
+
+** Six tools
+
+| Name | Magit-flavored shape |
+|------------------+--------------------------------------------------------------------|
+| =git_status= | =magit-git-insert "status" "--short" "--branch"= |
+| =git_log= | =magit-git-insert "log" "--oneline" (format "-n%d" N) ?--since= |
+| =git_diff= | =magit-git-insert "diff" REF1 REF2 "--" FILE= (each optional) |
+| =git_blame= | =magit-git-insert "blame" "--line-porcelain" FILE [-L S,E]= |
+| =git_show= | =magit-git-insert "show" REF= (message + full diff) |
+| =git_branches= | =magit-list-branches= (optionally filtered by =--list PATTERN=) |
+
+Each tool:
+- Validates =path= via =cj/gptel-git--toplevel-or-error=.
+- Calls =cj/gptel-git--insert= with the appropriate args.
+- Truncates via =cj/gptel-git--truncate=.
+- Registered as a separate tool with =gptel-make-tool= for
+ description / argv clarity at the model side.
+
+** Caps
+
+| Tool | Default cap | Hard cap |
+|---------------+----------------+--------------|
+| =git_status= | uncapped | uncapped |
+| =git_log= | 100 commits | 100 commits |
+| =git_diff= | 500 KB | 500 KB |
+| =git_blame= | 500 KB | 500 KB |
+| =git_show= | 500 KB | 500 KB |
+| =git_branches=| uncapped | uncapped |
+
+=git_log='s cap is on commit count; the rest cap output bytes.
+
+** :confirm posture
+
+All six tools are read-only. Same posture as the current
+implementation: =:confirm nil= (the model can call them
+autonomously, since they can't mutate state). The current
+git_status / git_log / git_diff already ship with =:confirm nil= --
+keeping it.
+
+** Tests
+
+Single file =tests/test-gptel-tools-git-tools.el=, replacing the
+three current per-tool test files. Real temp git repos via
+=process-file= (same pattern as current tests). Coverage per tool:
+Normal / Boundary / Error.
+
+Rough count: ~12 shared-helper tests (validator, insert wrapper,
+truncate) + ~7 per tool × 6 tools = ~54 tests total.
+
+* Migration
+
+1. Delete =gptel-tools/git_status.el=, =git_log.el=, =git_diff.el=.
+2. Delete =tests/test-gptel-tools-git-status.el=,
+ =test-gptel-tools-git-log.el=, =test-gptel-tools-git-diff.el=.
+3. Create =gptel-tools/git_tools.el= containing all six tools +
+ shared helpers.
+4. Create =tests/test-gptel-tools-git-tools.el=.
+5. Update =cj/gptel-local-tool-features= in =modules/ai-config.el=:
+ replace the three =git_*= symbols with one =git_tools= symbol
+ (or six if each tool wants its own feature file -- decide during
+ implementation).
+6. Make sure =modules/ai-config.el= can re-load without breaking the
+ live gptel session if the old tool symbols are still registered
+ from a prior Emacs.
+
+* Risks
+
+| Risk | Mitigation |
+|-------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------------|
+| Magit load slows first git_* tool call | One-time hit per session, scoped to the tool's :function body. Acceptable for opt-in tools. |
+| Tool registration name collision with the old git_* symbols | Use distinct names (git_status / git_log / git_diff stay; new tools join them). Or remove + restart. |
+| =magit-toplevel= behavior on TRAMP / remote paths | Validator rejects paths outside HOME first, so TRAMP paths can't reach magit-toplevel. |
+| =git_blame= exposes code surfaces the model shouldn't read | =:confirm nil= is the wrong posture if blame is sensitive. Open question for review. |
+| =git_show= reveals past-self commit message wording | Same as blame -- low risk on personal repo, but worth flagging. |
+
+* Open questions
+
+1. Build all six tools in one push, or phase status/log/diff first
+ and add blame/show/branches in a follow-up? My read: one push.
+ The helpers are shared, marginal cost of three more tools is
+ small, and the model gets meaningfully more useful git context.
+2. Consolidate the output-truncation helper into =system-lib.el=,
+ touching =web_fetch.el= and =update_text_file.el= for a cleaner
+ API? Or defer that to a separate refactor commit?
+3. =git_blame= and =git_show= -- =:confirm nil= or =:confirm t=?
+ Personal repo lowers the stakes but the model could ask for
+ blame on /any/ file under HOME.
+4. Tool feature symbols: one =git_tools= entry in
+ =cj/gptel-local-tool-features=, or six (one per tool)?
+ Currently each tool lives in its own provide-symbol file. With
+ the single-file design we'd register one feature symbol that
+ loads all six.
+
+* Effort estimate
+
+M (1-3 hours). Helpers + six tool wrappers + ~50 tests + migration.
+Most of the time is test authoring; the production code is small
+because magit absorbs the boilerplate.
diff --git a/docs/specs/gptel-network-tools-spec.org b/docs/specs/gptel-network-tools-spec.org
new file mode 100644
index 000000000..c28d54694
--- /dev/null
+++ b/docs/specs/gptel-network-tools-spec.org
@@ -0,0 +1,411 @@
+:PROPERTIES:
+:ID: 6388588c-dac2-4c52-97ad-2343ba1443fc
+:STATUS: not-started
+:END:
+#+TITLE: Design: gptel network tools
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft. Brainstorm output captured from a =/brainstorm= session on
+2026-05-16. Sibling to
+=docs/specs/gptel-git-tools-magit-backend-spec.org= and the broader theme
+hierarchy under =** TODO [#B] GPTel Tool Work= in =todo.org=.
+
+The conventional vs tail-sample exploration covered three categories
+(network, text/data, build/code). Network was selected as the next
+build target; this doc captures the network slice in full. The other
+two categories are referenced briefly and live as theme stubs under
+=*** TODO [#B] Filesystem Related Tools= and
+=*** TODO [#B] Development Workflow Related Tools= in =todo.org=.
+
+* Problem
+
+The current =gptel-tools/= set covers filesystem CRUD, web fetch, and
+git status/log/diff. When the user asks the agent "why can't I reach
+X?" or "what's on my LAN right now?" the agent has no affordances --
+it can only suggest commands the user runs manually.
+
+Network diagnosis is a recurring task on this laptop (homelab, mixed
+wifi/wired, occasional VPN, NetworkManager-managed connections). The
+agent should be able to run read-only network probes directly, return
+structured findings, and synthesize an explanation. Anything that
+mutates network state (=nmcli connection up=, route changes) stays
+behind =:confirm t=.
+
+* Non-goals
+
+- Active offensive scanning, vulnerability probes, or exploitation
+ tooling. Out of scope at the wrapper boundary -- nmap's
+ =-A=/=-O=/aggressive modes are rejected, NSE is deferred.
+- Scanning networks the user doesn't own. Public targets are gated
+ behind an explicit =external=t= flag and =:confirm t=.
+- Real-time/streaming inspection (=iftop=, =nethogs=, =tcpdump
+ follow=). Snapshot tools only; streaming tools don't fit the
+ request/response shape of gptel tools.
+- Replacing Magit's git tooling, mu4e's mail handling, or any other
+ Emacs-native workflow. Network tooling is the gap.
+
+* Approaches considered
+
+The =/brainstorm= run generated six candidate themes across three
+categories. Three conventional (high-prior), three tail samples
+(genuinely different regions of the option space). Network was
+chosen as the first build target; the others are recorded for
+follow-up sessions.
+
+** Recommended: network triage bundle (conventional #1)
+
+Five tools covering discovery, diagnostics, and inspection:
+
+| Tool | Purpose |
+|-------------------+--------------------------------------------------|
+| =net_diagnose= | "Why can't I reach X?" -- composite probe |
+| =net_discover= | "What's on this subnet?" -- LAN host discovery |
+| =net_services= | "What's listening on host X?" -- service detect |
+| =network_status= | "What's my current network state?" -- snapshot |
+| =dns_lookup= | Typed DNS query (A/AAAA/MX/NS/TXT/SRV/CAA) |
+
+Detailed in =* Design= below.
+
+*** Pros
+
+- Hits the highest-leverage daily question (connectivity diagnosis)
+ with a single mental entry point (=net_diagnose=).
+- Atomic tools (=dns_lookup=, =network_status=) for cases the
+ composite is too coarse for.
+- All read-only at the network layer; =:confirm nil= for RFC1918,
+ =:confirm t= for public targets.
+- nmap's two genuinely-unique capabilities (subnet discovery, service
+ enumeration) get first-class wrappers.
+
+*** Cons
+
+- Five tools is heavy for one category. Some are thin wrappers around
+ a single command.
+- Composite =net_diagnose= hides which sub-check fired; debugging the
+ tool itself is harder than debugging atomic tools.
+- nmap is the one tool that *can* get the user in trouble. Target
+ gating must be airtight or it's the wrong tool to ship.
+
+** Rejected: code-quality fan-out (conventional #2)
+
+=shellcheck_run=, =format_check= (black/prettier/gofmt/rustfmt/elisp,
+returns unified diff), =lint_run= (eslint/ruff/golangci-lint),
+=dot_render=, =mermaid_render=.
+
+Folded into =*** TODO [#B] Development Workflow Related Tools= as
+per-language work rather than a standalone bundle. Most of the per-
+language wins land in the existing prog-*.el modules' format-on-save
+and LSP attachments; the agent benefits more from /reading/ those
+buffers than from re-running the formatters via tool calls.
+
+** Rejected: GitHub workspace (conventional #3)
+
+=gh_pr_view=, =gh_issue_search=, =gh_run_logs=, =gh_pr_diff=.
+
+Overlaps with the magit-backend track (=gptel-git-tools-magit-backend=)
+for several queries. Better treated as a follow-on once the magit
+backend lands -- some queries are local (magit) and some are remote
+(gh), and the seam is clearer after the local side is built.
+
+** Rejected: DNS-chain inspector (tail sample)
+
+=dns_chain= walks NS -> A/AAAA -> MX -> SPF -> DMARC -> DKIM for a
+domain and returns a structured assessment with red flags ("MX
+missing TLS-RPT", "SPF includes >10 lookups", "DMARC policy=none").
+
+Real value when it's useful but probably 5 calls/year for this
+laptop. =dns_lookup= covers 90% of the recurring need; the chain
+walker is parked for a possible follow-on.
+
+** Rejected: awk_eval / sed_eval with explanation (tail sample)
+
+Accept snippet + sample input, return both the transformed output and
+a plain-English explanation of what the snippet does.
+
+Doubles work the model already does internally -- the model is
+already good at generating and explaining awk/sed. Real win would
+only be the actual execution against actual data, which the eshell
+escape hatch in the Filesystem section already covers.
+
+** Adopted as project convention: plan/apply split (tail sample)
+
+=rsync_plan= / =rsync_apply= split: plan always runs =--dry-run= and
+returns the file list and byte counts that *would* transfer; apply is
+a separate tool registration with =:confirm t=. Same shape for
+=nmcli= (status read vs connection mutate) and any other mutating
+tool.
+
+Promoted to a documented convention rather than a single tool: any
+mutating wrapper in =gptel-tools/= should split into a preview and an
+apply. The preview is =:confirm nil= so the agent can plan
+autonomously; the apply is =:confirm t= and stops cleanly for human
+review. Applies to =rsync=, =nmcli connection up=, =ssh= mutations,
+and the pandoc/ffmpeg/imagemagick output-writing tools in the
+Filesystem section.
+
+* Design
+
+** Tool 1: =net_diagnose=
+
+Composite "why can't I reach X?" probe. Given a target (hostname or
+IP), runs a sequence of sub-checks and returns a structured result:
+
+1. =dig +short= on the name (skip if target is an IP literal).
+2. =ping -c 3 -W 2= against the resolved IP.
+3. =traceroute -n -w 2 -q 1 -m 20= to the IP.
+4. If a port is given: =curl --max-time 5 -o /dev/null -sw '%{http_code}\n'=
+ for ports 80/443, or =nc -zv -w 3= for arbitrary TCP ports.
+
+Output shape (alist or plist returned to the model):
+
+#+begin_src text
+ ((target . "example.com")
+ (resolved-to . "93.184.216.34")
+ (dns-time-ms . 12)
+ (ping . ((sent . 3) (received . 3) (avg-ms . 14.2)))
+ (traceroute . ((hops . 8) (last-hop . "93.184.216.34")))
+ (port-check . ((port . 443) (status . "200") (tls . "ok"))))
+#+end_src
+
+Caps: total runtime <30s. Each sub-check has its own timeout. If a
+sub-check fails (no ping reply, no route, no DNS), the field carries
+the failure mode rather than aborting the whole call -- the agent
+needs the partial picture to reason.
+
+=:confirm nil=. Read-only.
+
+** Tool 2: =net_discover=
+
+Wraps =nmap -sn <subnet>= for LAN host discovery. Two argv shapes:
+
+- =net_discover ()= -- defaults to the current LAN, derived from
+ =ip route get 1.1.1.1= and the matching interface's =/24=.
+- =net_discover :subnet "192.168.1.0/24"= -- explicit subnet.
+
+Guardrails:
+
+- Subnet must be RFC1918, link-local (169.254/16), CGNAT (100.64/10),
+ or loopback. Public subnets rejected at the validator.
+- Subnet mask must be /22 or smaller (no /16 or wider). At /22 that's
+ ~1024 hosts -- enough for any homelab. Default home network is /24.
+- =--host-timeout 30s --max-retries 1= to bound runtime.
+
+Output: list of =(ip mac hostname state)= tuples.
+
+=:confirm nil= for RFC1918 / link-local / CGNAT / loopback. Public
+subnets never reach this tool (validator rejects).
+
+** Tool 3: =net_services=
+
+Wraps =nmap -sV= for service/version detection on a single host.
+
+Argv:
+
+- =:host= -- required. RFC1918 / link-local / CGNAT / loopback by
+ default. Public hosts require =:external t= which flips
+ =:confirm t=.
+- =:ports= -- optional port spec. Default: top-100 (=--top-ports
+ 100=). Custom lists allowed: ="22,80,443,5432,6379"= or
+ ="1-1024"=. Hard cap: 1024 ports total.
+- =:fast= -- if t, uses =--top-ports 20= for a quick check.
+
+Mode allowlist enforced at the wrapper: only =-sV= with optional
+=-p=. Reject =-A=, =-O=, =-T4=/=-T5=, =--script=, raw-packet flags.
+
+Output: list of =(port protocol state service version banner)=
+tuples, parsed from =-oG -= (greppable output).
+
+=:confirm nil= for RFC1918 / link-local / CGNAT / loopback.
+=:confirm t= for any target reachable only as a public IP/hostname.
+
+** Tool 4: =network_status=
+
+Snapshot of the local network state. Composite of:
+
+- =ip -br addr= -- interfaces and their addresses.
+- =ip route= -- routing table.
+- =nmcli -t -f NAME,TYPE,DEVICE,STATE connection show --active= --
+ active NetworkManager connections.
+- =ss -tulpn= (or =netstat -tulpn= fallback) -- listening sockets.
+- =resolvectl status= (or =/etc/resolv.conf= fallback) -- DNS
+ resolver state.
+
+Output: structured alist with sections for each.
+
+=:confirm nil=. Read-only.
+
+Note: this is also the candidate target for the plan/apply split if
+=nmcli connection up=/=down= ever lands as a tool -- =network_status=
+becomes the "plan" side and any mutation is a separate tool.
+
+** Tool 5: =dns_lookup=
+
+Typed DNS query. Argv:
+
+- =:name= -- required. The DNS name to query.
+- =:type= -- record type. Default =A=. Allowed: =A=, =AAAA=, =MX=,
+ =NS=, =TXT=, =SRV=, =CAA=, =CNAME=, =PTR=, =SOA=.
+- =:server= -- optional resolver. Default uses system resolver.
+ When set, must be RFC1918 or one of a small allowlist (=1.1.1.1=,
+ =8.8.8.8=, =9.9.9.9=) so the tool can't be used to probe arbitrary
+ hosts via DNS.
+
+Output: list of records with TTL. For =MX= and =SRV=, includes
+priority/weight/port. For =TXT=, the records are split into the
+quoted segments dig returns.
+
+=:confirm nil=. Read-only.
+
+** Shared helpers
+
+In =gptel-tools/network_tools.el= (single file, mirrors the
+magit-backend plan for git tools):
+
+- =cj/gptel-net--validate-target HOST &optional ALLOW-PUBLIC=
+ - Resolves HOST. Rejects unless resolved IP is RFC1918 /
+ link-local / CGNAT / loopback, unless ALLOW-PUBLIC is non-nil.
+ - Returns the resolved IP on success.
+
+- =cj/gptel-net--validate-subnet CIDR=
+ - Rejects non-private subnets and subnets wider than /22.
+ - Returns =(network mask)= on success.
+
+- =cj/gptel-net--current-lan=
+ - Derives the current /24 from =ip route get 1.1.1.1=.
+
+- =cj/gptel-net--run ARGS &key TIMEOUT=
+ - Wraps =process-file= with a uniform timeout, color/encoding
+ posture, and structured return =(exit-code stdout stderr)=.
+
+- =cj/gptel-net--parse-nmap-greppable STRING=
+ - Parses nmap =-oG -= output into structured tuples.
+
+- =cj/gptel-net--truncate TEXT MAX-BYTES=
+ - Same shape as the existing per-tool truncate helpers. Open
+ question whether this consolidates into =system-lib.el= alongside
+ the matching helpers in =web_fetch.el= and =update_text_file.el=.
+
+** Caps
+
+| Tool | Default cap | Hard cap |
+|------------------+------------------------+------------------------|
+| =net_diagnose= | <30s total runtime | <30s total runtime |
+| =net_discover= | /24 default, /22 max | /22 |
+| =net_services= | top-100 ports | 1024 ports |
+| =network_status= | uncapped (snapshot) | uncapped |
+| =dns_lookup= | uncapped | uncapped |
+
+** =:confirm= posture
+
+| Tool | RFC1918 target | Public target |
+|------------------+-------------------+-------------------------|
+| =net_diagnose= | =:confirm nil= | =:confirm t= |
+| =net_discover= | =:confirm nil= | rejected at validator |
+| =net_services= | =:confirm nil= | =:confirm t= |
+| =network_status= | =:confirm nil= | n/a (local snapshot) |
+| =dns_lookup= | =:confirm nil= | =:confirm nil= |
+
+=dns_lookup= stays =:confirm nil= for public names because DNS is
+read-only and innocuous. =net_diagnose= and =net_services= against
+public targets are gated because pinging/probing public hosts isn't
+*illegal* but it can trip rate-limits or get the user flagged on a
+managed network.
+
+** Tests
+
+Single file =tests/test-gptel-tools-network-tools.el=. Real subnets
+are not available in CI, so:
+
+- =net_discover= and =net_services= are stubbed via =cl-letf= on
+ =cj/gptel-net--run=, returning canned nmap output. Real nmap
+ invocation tested via one =:tags '(:integration)= test that runs
+ =nmap -sn 127.0.0.1/32= and asserts the parser handles the real
+ format.
+- =net_diagnose= sub-checks stubbed individually so each failure mode
+ can be exercised.
+- =network_status= sections stubbed per-command; one integration test
+ runs against the live system and asserts the structure parses.
+- =dns_lookup= stubbed against canned =dig= output; one integration
+ test against =localhost= via the system resolver.
+
+Rough count: ~12 shared-helper tests (validators, current-lan
+detector, parsers) + ~7 per tool x 5 tools = ~47 tests.
+
+** Risk surface
+
+| Risk | Mitigation |
+|-----------------------------------------------------------+---------------------------------------------------------------------|
+| nmap scan against an unintended target | Validator gates on resolved IP, not on the input string. Public |
+| | targets require explicit =:external t= flag + =:confirm t=. |
+| Scan triggers IDS/IPS on a corporate/managed network | Default modes are non-aggressive (=-sn=, =-sV= only). No =-A=, no |
+| | NSE, no high T-level. =:confirm t= for non-RFC1918 targets gives |
+| | the user a manual checkpoint. |
+| =net_diagnose= hangs on a slow target | Per-sub-check timeouts; total runtime cap; partial-failure return |
+| | rather than abort. |
+| nmap not installed on the system | =:command= check at module load via =cj/executable-find-or-warn= |
+| | (matching the prettier/pyright pattern documented in CLAUDE.md). |
+| Network tools shell out via =process-file= | argv-list invocation, no shell. =shell-quote-argument= unused |
+| | because no shell is involved. |
+| /tmp pollution or banner output writing to disk | All output captured to buffer via =process-file=, never written. |
+
+* Open questions
+
+1. *Default port set for =net_services=.* Top-100 (nmap default),
+ top-1000 (full default scan, slower), or a custom homelab-tuned
+ list (=22, 80, 443, 445, 3389, 5432, 6379, 8080, 8443, 9090, 9000,
+ 631=)? My read: top-100 default + =:fast t= for top-20 + custom
+ override for the homelab list when needed.
+2. *NSE in v1 or deferred?* Skip entirely (clean v1) or ship a small
+ allowlist (=ssl-cert=, =http-title=, =ssh-hostkey=)? My read:
+ skip in v1. If a real use case shows up (TLS audit), add a single
+ =net_tls_audit= tool wrapping just =ssl-enum-ciphers=/=ssl-cert=
+ rather than a generic NSE escape hatch.
+3. *Consolidate the truncate helper.* Same open question as the
+ magit-backend doc: move =cj/gptel-net--truncate= and its siblings
+ into =system-lib.el= as =cj/gptel-tools--truncate-bytes=, or keep
+ per-module? My read: consolidate when there are three callers
+ (web_fetch, update_text_file, network_tools all qualify).
+4. *Composite vs atomic for =net_diagnose=.* Build it as one
+ composite, or break it into =ping_run=, =traceroute_run=,
+ =port_check= and let the agent compose? My read: composite is
+ better -- the agent reasons in "diagnose-this-target" terms more
+ often than in "just-ping-this". Atomic sub-tools can be added
+ later if the composite proves coarse-grained.
+5. *Promote plan/apply split to documented convention now?* Or wait
+ until a second tool exercises it (post-rsync)? My read: document
+ the convention in the Filesystem section body now, since pandoc /
+ ffmpeg / imagemagick all benefit, even before any of them ship.
+6. *nmcli mutation tools.* Out of scope for this doc but worth
+ flagging: =nmcli connection up <name>= / =nmcli connection down
+ <name>= / =nmcli device wifi connect <ssid>=. These would be the
+ first apply-side tools under the plan/apply convention, with
+ =network_status= as the plan side.
+
+* Effort estimate
+
+M (1-3 hours). Five tools + shared helpers + ~47 tests. Most of the
+time is test authoring (canned nmap output, dig output, ss output);
+production code is small because each tool is a thin =process-file=
+wrapper plus a parser.
+
+* Next steps
+
+- Resolve open questions #1 and #2 before any code lands (the
+ =net_services= shape can't be finalized without them).
+- Once approved, the work attaches to =*** TODO [#B] (Network bundle:
+ net_diagnose / net_discover / net_services / network_status /
+ dns_lookup)= -- a new theme under =*** TODO [#B] (Networking tools
+ category)= which itself becomes a new top-level under =** TODO [#B]
+ GPTel Tool Work= in =todo.org=, peer to the existing Filesystem
+ section.
+- Implementation follows =/start-work= flow: TDD, characterization
+ tests for the parsers first (canned nmap/dig/ss fixtures), then
+ the wrappers, then the registrations in
+ =cj/gptel-local-tool-features=.
+- After landing, revisit candidate #6 (plan/apply split) -- the
+ first apply-side tool (=nmcli connection up=, =rsync_apply=,
+ pandoc-output) exercises the convention end-to-end.
diff --git a/docs/specs/init-load-graph-spec-doing.org b/docs/specs/init-load-graph-spec-doing.org
new file mode 100644
index 000000000..05dd9e0a3
--- /dev/null
+++ b/docs/specs/init-load-graph-spec-doing.org
@@ -0,0 +1,833 @@
+:PROPERTIES:
+:ID: e1fd137e-e164-42f4-a658-f4d32fbe3228
+:STATUS: doing
+:END:
+#+TITLE: Design: Untangle the init.el Load Graph
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-04
+
+* Status
+
+Draft. Specification only. No load-order implementation is part of this design
+document.
+
+* Problem
+
+=init.el= is currently both the startup script and the dependency graph. It
+eagerly requires almost every module in a fixed order, so many modules work
+because some earlier require happened to define a variable, keymap, path
+constant, hook owner, package, or helper function.
+
+That creates four practical problems:
+
+- Standalone module loading is unreliable. A module may byte-compile but fail at
+ runtime unless enough of =init.el= was loaded first.
+- Startup has unnecessary work. Optional workflows, heavy packages, timers,
+ network-facing integrations, and media tools load even when not used.
+- Side effects are hard to audit. Keybindings, timers, global hooks, server
+ setup, package configuration, and command definitions are mixed together.
+- Test boundaries are blurry. Tests often need to simulate init order instead of
+ loading the unit under test directly.
+
+The target is not "lazy load everything." The target is an explicit, testable
+load graph where eager startup is a small documented set, optional workflows
+load from commands/hooks/autoloads, and module dependencies are declared by the
+modules that use them.
+
+* Goals
+
+- Make module ownership obvious: libraries, keymap ownership, package
+ configuration, commands, and startup side effects should be distinguishable.
+- Make dependencies explicit with ordinary =require=, =autoload=, or documented
+ hook/package boundaries.
+- Reduce eager startup load without breaking existing keybindings or daily
+ workflows.
+- Keep the migration incremental and reversible. Each batch should be small
+ enough to test and inspect.
+- Preserve interactive behavior for configured workflows, including calendar
+ sync, Org capture/agenda, mail, F-keys, and media commands.
+- Improve testability: modules should either load directly or fail with a clear
+ missing external package/config message.
+
+* Non-Goals
+
+- Rewriting the whole configuration into one framework or literate init.
+- Removing =use-package=. This design assumes package config modules continue to
+ use it where appropriate.
+- Eliminating all top-level forms. Some top-level configuration is appropriate,
+ especially for foundational Emacs settings and hook registration.
+- Solving package bootstrap in =early-init.el=. That is tracked by the separate
+ "Move package bootstrap out of =early-init.el= where possible" project.
+- Rotating calendar feed URLs or designing secret storage beyond the local
+ calendar config path already introduced. Token rotation remains a separate
+ security task.
+- Consolidating all scattered utility helpers. Utility consolidation is a
+ sibling project because it changes helper ownership, tests, and call sites
+ without necessarily changing startup load order.
+
+* Principles
+
+** Eager Requires Are Allowed Only With A Reason
+
+An eager require in =init.el= should satisfy one of these conditions:
+
+- It establishes basic Emacs behavior needed for the rest of startup.
+- It defines shared constants or helpers used by many eager modules.
+- It owns the global key prefix/keymap registration system.
+- It configures core UI behavior that should be visible in the first frame.
+- It starts a user-approved startup service that cannot be triggered lazily.
+
+Everything else should be a candidate for autoload, hook-based loading,
+=with-eval-after-load=, or a command wrapper.
+
+** Modules Declare What They Use
+
+If a module calls a function or reads a variable at runtime, it should not rely
+on init order unless that dependency is an explicit startup contract.
+
+Preferred dependency forms:
+
+- Runtime dependency: =(require 'module)=.
+- Optional runtime dependency: =(require 'module nil t)= with a clear degraded
+ behavior.
+- Macro/compile-time dependency: =(eval-when-compile (require 'module))=.
+- Command-only dependency: =(autoload 'command "module" nil t)= or a lazy
+ command wrapper.
+- Package-bound dependency: =use-package :after=, =:hook=, =:commands=, or
+ =with-eval-after-load=.
+
+Avoid test-only shims in production modules such as "define this keymap if it
+does not exist." Tests should provide stubs or load the real owner.
+
+** Utility Extraction Should Stay Small And Evidence-Based
+
+Some hidden dependencies exist because generic helpers live in feature modules
+where they were first needed. Moving those helpers into =system-lib= can make
+dependencies clearer, but utility extraction should not become part of every
+load-order change by default.
+
+Extract a helper only when:
+
+- at least two callers need substantially the same behavior,
+- the helper can stay dependency-light enough for foundation startup,
+- tests can move with the helper,
+- the extraction is atomic and easy to review.
+
+Avoid building a broad utility suite speculatively. Prefer one helper, one
+tested extraction, one commit.
+
+** Keymaps Have Owners
+
+=keybindings.el= should own global prefixes, especially =cj/custom-keymap= and
+the =C-;= prefix. Feature modules may define local maps or command maps, but
+registration into global prefixes should go through a small convention/helper so
+load order is not a hidden dependency.
+
+** Side Effects Are Named And Isolated
+
+Side effects include:
+
+- starting timers,
+- starting processes,
+- calling network-facing sync/fetch commands,
+- setting global keybindings,
+- mutating global hooks,
+- opening files/buffers,
+- enabling global modes,
+- loading large packages solely for optional commands.
+
+Each side effect should have one of:
+
+- a documented eager reason,
+- an interactive command,
+- a hook/package boundary,
+- a noninteractive/batch guard,
+- a test that proves the side effect does not happen in the wrong context.
+
+* Target Architecture
+
+** Layer 0: Early Startup
+
+Owned by =early-init.el=. Should remain limited to startup mechanics that must
+happen before package/UI initialization.
+
+Examples:
+
+- package archive/bootstrap policy,
+- native-comp/cache startup knobs that must be early,
+- disabling expensive default UI before first frame.
+
+This design does not refactor =early-init.el= except to avoid adding new load
+graph responsibilities to it.
+
+** Layer 1: Foundation
+
+Small eager set required before most other modules can safely load.
+
+Expected contents:
+
+- =system-lib=
+- =user-constants=
+- =host-environment=
+- =system-defaults=
+- =keyboard-compat=
+- =keybindings=
+- maybe =config-utilities=, if debug helpers are intentionally eager
+
+Foundation modules should be able to load in batch mode without package,
+network, timer, or UI-package side effects.
+
+Adding a new Layer 1 module requires a coordinated update to the
+=system-lib.el= dependency budget in [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]].
+
+Topic libraries introduced by the utility project join Layer 1 only when their
+first consumer is foundation-eager. Otherwise they are Layer 2 and loaded by an
+explicit =require= from their eager consumers. Add each new topic library to the
+module category table before migrating its first consumer.
+
+** Layer 2: Core UX
+
+Eager or near-eager modules that shape the first interactive session.
+
+Expected contents:
+
+- basic text/editing defaults,
+- core UI frame/theme/font/modeline behavior,
+- selection/completion framework,
+- F-key development entry points,
+- VC/test/coverage command entry points.
+
+Core UX modules may configure packages, but heavy features should still use
+=:commands=, =:hook=, or =:defer= where practical.
+
+** Layer 3: Domain Workflows
+
+Org, programming, mail, browser, media, AI, and integration modules. These
+should generally load through hooks, commands, package =:after= clauses, or
+workflow-specific entry commands.
+
+Examples:
+
+- Org capture/agenda can remain eager if the user's daily workflow needs it,
+ but exporters and optional extensions can be deferred.
+- Language modules should load from mode hooks or file associations, not because
+ every startup might edit every language.
+- Mail/media/AI/rest tools should register commands eagerly if needed, then load
+ heavy packages only on use.
+
+** Layer 4: Optional And Experimental
+
+Entertainment, modules in test, diagnostics, and rarely used tools. These should
+not be required by default unless the user explicitly chooses that behavior.
+
+Examples:
+
+- =games-config=
+- =music-config=
+- =lorem-optimum=
+- =gloss-config=
+- optional IRC/Slack/feed/media modules when not in active use
+
+* Module Categories
+
+This is a first-pass classification to guide implementation. It is not an
+architectural truth table; each module should be confirmed while refactoring.
+
+Category key:
+
+- =F= foundation or shared library/config.
+- =C= core eager UX.
+- =P= package configuration that should usually be hook/command/package loaded.
+- =D= domain workflow that may have a user-visible eager reason.
+- =S= startup side-effect or timer/process owner.
+- =O= optional, entertainment, experimental, or rarely used.
+- =L= pure-ish library/command helpers that should be easy to load directly.
+
+| Module | Category | Expected final load shape | Notes |
+|--------+----------+---------------------------+-------|
+| =early-init= | F | early | Layer 0; see Non-Goals. |
+| =system-lib= | F/L | eager | Low-level helpers. Keep side-effect free. |
+| =cj-process= | F/L | TBD per first consumer | Topic library placeholder; see utility-consolidation Group 3. |
+| =cj-org-text= | F/L | TBD per first consumer | Topic library placeholder; see utility-consolidation Group 6. |
+| =cj-cache= | F/L | TBD per first consumer | Topic library placeholder; see utility-consolidation Group 7. |
+| =user-constants= | F | eager, then split | Split pure path constants from directory creation/failure behavior. |
+| =host-environment= | F/L | eager | Predicate helpers. |
+| =system-defaults= | F/S | eager | Owns global Emacs defaults, server/recentf/minibuffer hooks. |
+| =keyboard-compat= | F/S | eager | Terminal/GUI keyboard setup hooks. |
+| =keybindings= | F/C | eager | Owner of =cj/custom-keymap= and global prefixes. |
+| =config-utilities= | C/O | eager or command-loaded | Debug keymap may be eager; heavy org parsing commands can lazy require. |
+| =custom-case= | L/C | autoload commands + key registration | Text command helper. |
+| =custom-comments= | L/C | autoload commands + key registration | Text command helper. |
+| =custom-datetime= | L/C | autoload commands + key registration | Text command helper. |
+| =custom-buffer-file= | L/C | eager only if remaps required | Has file/process helpers and keymap registration. |
+| =custom-line-paragraph= | L/C | autoload commands + key registration | Requires =expand-region= at command boundary if possible. |
+| =custom-misc= | L/C | autoload commands + key registration | Misc commands. |
+| =custom-ordering= | L/C | autoload commands + key registration | Text command helper. |
+| =custom-text-enclose= | L/C | autoload commands + key registration | Text command helper. |
+| =custom-whitespace= | L/C | autoload commands + key registration | Text command helper. |
+| =external-open= | L/D | autoload commands | Runtime requires environment/process helpers explicitly. |
+| =media-utils= | D | command-loaded | Downloads/players should run only by command. |
+| =auth-config= | F/D | eager or package-after | Auth setup may be core; GPG commands should remain commands. |
+| =keyboard-macros= | C | eager or keymap-only | Lightweight command/key owner. |
+| =system-utils= | L/C | eager or command-loaded | Timers/process monitor utilities. |
+| =text-config= | C/P | eager hooks | General text defaults and package config. |
+| =undead-buffers= | C | eager if remaps desired | Global kill-buffer remaps. |
+| =browser-config= | D/P | command/package-loaded | Browser workflow. |
+| =coverage-core= | C/L | eager command entry | F7 entry point and backend registry. |
+| =coverage-elisp= | C/P | eager after core | Backend registration; keep cheap. |
+| =dev-fkeys= | C | eager | F4/F6 command entry points. |
+| =ui-config= | C/S | eager | Cursor/UI defaults; post-command hook should be documented. |
+| =ui-theme= | C | eager + explicit startup call | Theme load stays explicit in init. |
+| =ui-navigation= | C/P | eager | Window keybindings and winner/buffer-move config. |
+| =font-config= | C/P/S | eager or first-frame | Font hooks/font installation checks need guards. |
+| =selection-framework= | C/P | eager | Completion stack; likely core UX. |
+| =modeline-config= | C/S | eager | Mode line and VC cache hooks. |
+| =mousetrap-mode= | C | eager if global behavior desired | Prevents accidental mouse edits. |
+| =popper-config= | C/P | eager if enabled, else remove/defer | Existing disabled-state question remains. |
+| =chrono-tools= | D/P | command-loaded | Calendar/timer commands; sound path dependency explicit. |
+| =diff-config= | C/P | eager or package-loaded | Diff/merge UX. |
+| =erc-config= | O/D/P | command-loaded | IRC should not be startup load by default. |
+| =slack-config= | O/D/P | command-loaded | Slack package/auth and which-key registration should be after-load. |
+| =eshell + term-config= | D/P | command/hook-loaded | Shell/terminal packages. |
+| =help-utils= | L/D | autoload commands | Search/help commands. |
+| =help-config= | C/P | eager or after help | Info/man/help config. |
+| =tramp-config= | D/P | package-loaded | Remote shell configuration. |
+| =calibredb-epub-config= | O/D/P | command-loaded | Ebook workflow. |
+| =dashboard-config= | C/S | eager only if startup dashboard desired | Opens/initializes landing page behavior. |
+| =dirvish-config= | D/P | command/hook-loaded | File manager; runtime constants explicit. |
+| =dwim-shell-config= | D/P | command-loaded | Shell commands from Dired/Dirvish. |
+| =elfeed-config= | O/D/P | command-loaded | Feed reader/podcast workflow. |
+| =eww-config= | D/P | command-loaded | Web browsing helpers. |
+| =flyspell-and-abbrev= | C/P | hooks | Text-mode spelling/abbrev. |
+| =httpd-config= | O/D/P | command-loaded | Local web server. |
+| =latex-config= | D/P | hook-loaded | Existing WIP comment should become tasks or be removed. |
+| =mail-config= | D/P | command-loaded or eager by choice | Heavy mu4e/org-msg; daily workflow may justify eager command registration. |
+| =markdown-config= | D/P | mode-loaded | Markdown package config. |
+| =pdf-config= | D/P | file/mode-loaded | Heavy PDF packages should load on PDF open. |
+| =quick-video-capture= | O/D/S | command/protocol-loaded | Top-level timers should be removed or guarded. |
+| =video-audio-recording= | O/D/S | command-loaded | External process/device probing only on command. |
+| =transcription-config= | O/D/P | command-loaded | Auth/process workflow. |
+| =weather-config= | O/D/P | command-loaded | Optional command. |
+| =prog-general= | C/P/S | eager or hooks | Projectile, treesit policy, LSP ownership concerns. |
+| =test-runner= | C/L | eager command entry | Test keymap and project-scoped state. |
+| =vc-config= | C/P | eager command entry | Magit/git keymap; clone command hardening separate. |
+| =flycheck-config= | C/P | hooks | General linting. |
+| =prog-training= | O/D/P | command-loaded | Exercism/Leetcode optional. |
+| =prog-c= | D/P | mode-loaded | C hooks and compile command. |
+| =prog-go= | D/P | mode-loaded | Go hooks/LSP. |
+| =prog-lisp= | D/P | mode-loaded | Lisp package config. |
+| =prog-lsp= | C/P | package policy owner | Should consolidate generic LSP policy. |
+| =prog-shell= | D/P/S | mode-loaded | after-save executable hook should be opt-in or scoped. |
+| =prog-python= | D/P | mode-loaded | Python hooks/LSP. |
+| =prog-webdev= | D/P | mode-loaded | Webdev modes/LSP. |
+| =prog-json= | D/P | mode-loaded | JSON formatting/mode config. |
+| =prog-yaml= | D/P | mode-loaded | YAML formatting/mode config. |
+| =org-config= | C/D/P | eager | Core Org behavior likely eager. |
+| =org-agenda-config= | D/S | eager by workflow, timers guarded | Agenda cache lifecycle project tracks cleanup. |
+| =org-babel-config= | D/P | after Org | Babel languages package config. |
+| =org-capture-config= | D/P | eager if capture hot path | Protocol/capture templates. |
+| =org-contacts-config= | D/P | after Org/mail | Contacts workflow. |
+| =org-drill-config= | O/D/P | command-loaded | Optional drill workflow. |
+| =org-export-config= | D/P | command-loaded | Export packages/processes. |
+| =hugo-config= | D/P | command-loaded | Blog workflow. |
+| =org-reveal-config= | O/D/P | command-loaded | Presentation workflow. |
+| =org-refile-config= | D/S | eager by workflow, timers guarded | Refile cache lifecycle project tracks cleanup. |
+| =org-roam-config= | D/P/S | eager by workflow | Capture/finalize hooks, db. |
+| =org-webclipper= | O/D/P | protocol/command-loaded | Global temp state cleanup tracked separately. |
+| =org-noter-config= | O/D/P | command-loaded | PDF notes workflow. |
+| =ai-config= | D/P | command-loaded | GPTel commands; avoid loading all AI tooling at startup. |
+| =ai-conversations= | D/L/S | after gptel | Autosave hook and persistence path need coverage. |
+| =restclient-config= | D/P | command-loaded | API exploration. |
+| =calendar-sync= | D/S | eager only if configured, batch safe | Private config path and noninteractive guard exist. |
+| =reconcile-open-repos= | D/S | command-loaded | Repo scanning/reconciliation should not run at startup. |
+| =local-repository= | O/D/P | command-loaded | Local package mirror workflow. |
+| =music-config= | O/D/P/S | command-loaded | EMMS/keymap optional, hooks only after EMMS. |
+| =games-config= | O | command-loaded | Optional. |
+| =lorem-optimum= | O/L | command-loaded | Module in test. |
+| =jumper= | O/L | command-loaded | Navigation helper. |
+| =system-commands= | D/S | command-loaded | High-impact commands; defensive work tracked separately. |
+| =gloss-config= | O/D/P | command-loaded | Glossary workflow. |
+| =wrap-up= | S | eager if desired | End-of-startup buffer bury timer. |
+| =ledger-config= | O/D/P | mode-loaded | Not currently required by init. |
+| =mu4e-org-contacts-integration= | D/L | after mu4e/org-contacts | Loaded by mail workflow. |
+| =mu4e-org-contacts-setup= | D/L | after mu4e/org-contacts | Setup helper. |
+| =org-agenda-config-debug= | O/L | command/debug-loaded | Debug helper. |
+| =show-kill-ring= | O/L | command-loaded | Not currently required by init. |
+
+* Module File Header Standard
+
+Each module should eventually declare its load-graph contract in its own
+commentary header. The category table above is the seed view; module headers
+are the contributor-facing contract that travels with the code.
+
+Required header lines, after =;;; Commentary:=:
+
+1. =;; Layer: <0|1|2|3|4> (<layer name>).=
+2. =;; Category: <F|C|P|D|S|O|L>=.
+3. =;; Load shape: <eager|hook|mode|command|after-load>=.
+4. =;; Eager reason:= one-line justification when load shape is =eager=,
+ omitted otherwise.
+5. =;; Top-level side effects:= timer, process, hook, package, network,
+ buffer mutation, file write, or =none=.
+6. =;; Runtime requires:= explicit runtime module/package list.
+7. =;; Direct test load: <yes|conditional|no>=, with a brief reason when not
+ =yes=.
+
+Optional:
+
+- =;; See also:= references to tests and design docs.
+
+Worked example:
+
+#+begin_src emacs-lisp
+;;; calendar-sync.el --- One-way calendar synchronization to Org -*- lexical-binding: t; -*-
+;;
+;;; Commentary:
+;;
+;; Layer: 3 (Domain Workflow).
+;; Category: D/S.
+;; Load shape: eager only when calendar-sync.local.el configures calendars.
+;; Eager reason: daily-driver workflow; user expects calendars synced at first
+;; session. Top-level startup is guarded so batch/test loads do not start
+;; timers or network fetches.
+;; Top-level side effects: timer, network fetch, file writes to calendar Org
+;; files. Guarded by noninteractive/config checks.
+;; Runtime requires: user-constants, seq, subr-x.
+;; Direct test load: yes (batch-safe; private config is optional).
+;;
+;; See also: docs/specs/init-load-graph-spec-doing.org, tests/test-calendar-sync.el.
+;;
+;;; Code:
+#+end_src
+
+Phase 1 should annotate every module required by =init.el= with this header.
+Later validation can assert that every required module declares the seven
+required lines.
+
+* Proposed Load Shape
+
+Migration commits should use conventional commit prefixes consistently:
+
+- =refactor:= for behavior-preserving load-order, dependency, keymap, and lazy
+ loading migrations.
+- =feat:= only when adding a new user-visible capability.
+- =test:= for test-only follow-up work.
+- =docs:= for spec, inventory, design updates, and module-header annotations,
+ even when those annotations touch =modules/*.el= files.
+
+Default deferral mechanism:
+
+- Prefer =use-package :commands= for command-driven deferrals.
+- Prefer =use-package :mode= when loading is file-extension or major-mode
+ driven.
+- Prefer =use-package :hook= when the consumer is a mode-hook function.
+- Use explicit =(autoload 'command "module" nil t)= only when the command is
+ not naturally owned by a =use-package= form.
+
+** Phase 1: Inventory And Contracts
+
+Do not change load order yet.
+
+1. Keep the current eager =init.el= order.
+2. Create/maintain =docs/design/module-inventory.org= as a living inventory
+ with:
+ - module name,
+ - category,
+ - eager/deferred target,
+ - known runtime dependencies,
+ - top-level side effects,
+ - tests that cover standalone load or command behavior.
+3. Annotate every module required by =init.el= with the module header standard.
+4. Convert vague comments in =init.el= into tasks or remove them:
+ - =latex-config= "WIP need to fix",
+ - =prog-shell= "combine elsewhere",
+ - "Modules In Test" section.
+5. Add lightweight standalone-load smoke tests for the lowest-level modules.
+
+Inventory rules:
+
+- The module table in this spec seeds the inventory.
+- =docs/design/module-inventory.org= is the living per-module truth after Phase
+ 1 starts.
+- Every module required by =init.el= must be represented before Phase 2 starts.
+- Discoveries during later phases update the inventory.
+- This inventory is independent from the helper inventory owned by
+ [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]].
+
+Exit criteria:
+
+- Every module required by =init.el= has a category and target load shape.
+- Every eager survivor has a documented reason.
+- The inventory identifies top-level timers/process/network-ish side effects.
+- Every module required by =init.el= has the required load-graph header lines.
+
+** Phase 2: Explicit Dependencies
+
+Still do not significantly change startup behavior.
+
+1. For each module batch, load it directly in batch mode.
+2. Fix hidden dependencies by adding real =require=, =autoload=, or package
+ boundaries.
+3. Remove production shims that only exist because tests load modules in an
+ incomplete environment.
+4. If a keymap dependency is hidden, document it and make the dependency
+ explicit with =require= or =autoload=. Do not refactor into the registration
+ convention until Phase 3. When the hidden dependency is on
+ =cj/custom-keymap= itself, add =(require 'keybindings)= to the consuming
+ module; Phase 3 replaces these direct dependencies with the registration
+ API.
+5. When a hidden dependency is really a duplicated generic helper, either:
+ - hand the extraction to the utility-consolidation sibling project when it
+ is in scope there, or
+ - leave it in place and record it under that project.
+
+Suggested order:
+
+- Foundation and libraries.
+- Text/editing command modules.
+- UI modules.
+- Programming modules.
+- Org modules.
+- Optional integrations.
+
+Exit criteria:
+
+- Direct module load either succeeds or fails with a clear missing external
+ package/config message.
+- =make test-file FILE=test-all-comp-errors.el= passes.
+- New tests cover any helper extracted while fixing dependencies.
+- Helper extraction remains dependency-light and does not pull heavy packages
+ into foundation startup.
+
+** Phase 3: Keymap Registration Boundary
+
+Introduce a small keymap registration API before deferring many feature modules.
+
+Possible API:
+
+#+begin_src emacs-lisp
+(defun cj/register-prefix-map (key map label)
+ "Register MAP under KEY in `cj/custom-keymap' with LABEL for which-key."
+ ...)
+
+(defun cj/register-command (key command label)
+ "Register COMMAND under KEY in `cj/custom-keymap' with LABEL for which-key."
+ ...)
+#+end_src
+
+Design rules:
+
+- =keybindings.el= owns =cj/custom-keymap= and the global =C-;= binding.
+- Feature modules may define maps and commands without mutating global keys
+ directly.
+- Which-key labels must be registered after which-key loads.
+- Tests can assert key resolution without loading every feature package.
+
+Exit criteria:
+
+- Modules no longer need to assume =cj/custom-keymap= exists at top level
+ except through the registration API.
+- Existing =C-;= bindings continue to resolve.
+- Which-key labels for documented prefixes remain available.
+
+** Phase 4: Defer Low-Risk Optional Modules
+
+Start with modules that are unlikely to affect first-frame startup.
+
+Candidate batch:
+
+- =games-config=
+- =music-config=
+- =weather-config=
+- =gloss-config=
+- =lorem-optimum=
+- =jumper=
+- =httpd-config=
+- =prog-training=
+
+For each module:
+
+1. Keep its user-facing command/key available via the default deferral mechanism
+ above.
+2. Move package loading into =use-package :commands=, =:hook=, =:mode=, or an
+ explicit autoload/wrapper only when the default does not fit.
+3. Run targeted tests and an interactive smoke check.
+
+Exit criteria:
+
+- Startup no longer requires the module eagerly.
+- User command still works from a fresh Emacs session.
+- Module-specific tests pass.
+
+** Phase 5: Defer Heavy Domain Modules
+
+Candidate batch:
+
+- =pdf-config=
+- =calibredb-epub-config=
+- =video-audio-recording=
+- =transcription-config=
+- =mail-config=
+- =ai-config=
+- =restclient-config=
+- =elfeed-config=
+- =erc-config=
+- =slack-config=
+
+These need more care because they often combine package setup, auth, keymaps,
+processes, hooks, and user workflows.
+
+Exit criteria for each:
+
+- Commands are discoverable before package load.
+- Package load happens through the default deferral mechanism: command, hook,
+ mode, or explicit startup opt-in.
+- Auth and private config are not read until necessary unless the user opts in.
+- Batch/test startup does not start network/process work.
+
+Private config opt-in follows the =calendar-sync.local.el= precedent: a module
+reads =<module-name>.local.el= when readable, the file is gitignored, and the
+module degrades cleanly when the file is missing. Token rotation is a separate
+security task; this convention is about config presence, not secret protection.
+
+** Phase 6: Revisit Org And Programming Eagerness
+
+Org and programming modules are daily-use, so the goal is not blindly deferring
+everything.
+
+Programming target:
+
+- Keep generic programming defaults and F-key command entry points available.
+- Load language-specific modules by major mode.
+- Consolidate generic LSP policy under =prog-lsp=.
+ - Move to =prog-lsp=: global LSP toggles such as =lsp-idle-delay=,
+ =lsp-log-io=, =lsp-enable-folding=, =lsp-enable-snippet=,
+ =lsp-headerline-breadcrumb-enable=, and file-watch ignore lists.
+ - Keep per-language: server client settings such as
+ =lsp-clients-clangd-args= and =lsp-pyright-*=, plus language-mode hook
+ wiring.
+- Tree-sitter grammar auto-install is always on; the project policy is global
+ allow. =treesit-auto-install= is =t= without per-language conditionals.
+
+Org target:
+
+- Keep these daily first-session workflows eager: =org-config=,
+ =org-agenda-config=, =org-capture-config=, =org-refile-config=,
+ =calendar-sync= when local config is present, and =org-roam-config=.
+- Defer exporters, reveal, drill, noter, webclipper, and optional publishing
+ pieces behind commands/hooks.
+- Normalize agenda/refile cache lifecycle before changing timer behavior. This
+ is behavioral normalization within the load-graph project; the shared
+ =cj-cache.el= extraction is owned by utility-consolidation Phase 5 and may
+ follow.
+
+The =prog-lsp= consolidation and tree-sitter policy decisions are owned by this
+load-graph project. Utility consolidation owns reusable helper extraction, not
+programming policy.
+
+Exit criteria:
+
+- Common daily Org/programming workflows work from a fresh session.
+- Optional exporters/languages load when used.
+- Timers are guarded in batch/test contexts.
+
+* Adjacent Project: Utility Consolidation
+
+The review of this spec identified a related but distinct architectural
+problem: helper functions are scattered across feature modules, sometimes with
+duplicated behavior. This matters to the load graph because modules can become
+coupled to whichever feature file happened to define a useful helper first.
+
+This should be tracked as a sibling project, not folded into the load-graph
+project. The load-graph project asks "when and why does this module load?" The
+utility consolidation project asks "which module should own this reusable
+behavior?" Those questions overlap, but their changes have different risk and
+rollback shapes.
+
+This sibling project can run beside Phase 2. When explicit-dependency work finds
+a generic duplicated helper, the sibling project owns the extraction commit when
+the helper is in scope for that project. See
+[[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]] for candidate
+helpers, naming rules, dependency budgets, migration phases, and test policy.
+
+* Testing Strategy
+
+** Static/Batch Tests
+
+Add or extend tests for:
+
+- Direct module load smoke tests for modules in each batch.
+- Header validation: every module required by =init.el= declares the seven
+ required load-graph header lines.
+ - Test file: =tests/test-init-module-headers.el=.
+ - Assertion shape: inspect every module required by =init.el=, read its
+ commentary header, and fail with the missing line names for any absent
+ required header line.
+- Keymap registration: prefix maps and commands resolve without requiring the
+ feature implementation package.
+- No startup timers/processes in batch for side-effect modules.
+- =init.el= startup smoke in batch, where possible.
+- Byte/native compile smoke via existing =test-all-comp-errors.el=.
+
+Test files for this project use =test-init-<feature>.el=, for example
+=test-init-module-headers.el= and =test-init-keymap-registration.el=. This keeps
+load-graph validation tests distinct from per-module unit tests.
+
+Header validation runs directly against module files. It does not depend on the
+final =docs/design/module-inventory.org= format, which remains a Phase 1
+authoring decision.
+
+** Automated Smoke Checks
+
+Automate every smoke item that can run in batch:
+
+- Important keybindings resolve to the intended command symbols, including
+ =C-;= prefixes and F4/F6/F7 entry points.
+- Org capture and agenda command entry points load or produce expected
+ batch-safe guidance.
+- Calendar sync status reports configured/no-config state without starting
+ timers or network fetches in batch.
+- Optional commands touched in the batch autoload and resolve.
+- Non-graphical interactive flows use =execute-kbd-macro= or
+ =with-simulated-input= where practical.
+
+These checks should run under =make test= for every migration commit.
+
+** Manual Smoke Checks
+
+Each migration batch should be followed by an interactive restart and checklist:
+
+- First frame appears with expected theme/font/modeline.
+- =C-;= prefix appears and key descriptions are present.
+- Magit opens.
+- Mail command opens or gives expected package/config guidance.
+- Refile target lookup works in an interactive session.
+- Any optional command changed in the batch runs end to end.
+- If daemon mode is part of normal use, run the visual checklist once via
+ regular =emacs= and once via =emacsclient= against a running daemon.
+
+** Performance Checks
+
+Before and after major batches:
+
+- Record =emacs-init-time=.
+- Record a startup profile baseline and diff, preferably with =benchmark-init=
+ if enabled for the phase.
+- =benchmark-init= is installed via package.el. The activation block in
+ =early-init.el= is commented; uncomment it locally during phases that need
+ profiling and do not commit the activation. Profile output goes to
+ =.profile/=, which should stay gitignored.
+- Suggested workflow:
+ - =make profile-baseline= records =emacs-init-time= and a startup profile to
+ =.profile/baseline.txt=.
+ - =make profile-diff= records the current run and compares it to the phase
+ baseline.
+- Keep a simple note of eagerly loaded feature count from
+ =cj/info-loaded-features= or equivalent.
+
+Performance is a supporting signal. Correctness and explicit dependencies are
+the primary acceptance criteria. Startup regressions larger than roughly 50 ms
+against the phase baseline should be investigated and explained; after several
+stable baseline runs, this can become a stricter gate.
+
+* Acceptance Criteria
+
+The project is complete when:
+
+- =init.el= contains only documented eager requires and explicit startup calls.
+- Optional modules no longer load merely because Emacs started.
+- Each module required by =init.el= has a category and eager/deferred rationale.
+- Modules that remain eager have no hidden dependencies on arbitrary earlier
+ init order.
+- Global key registration has a central owner/convention.
+- Top-level timers/process/network work is either removed, guarded, or
+ documented as intentional.
+- Full =make test= passes.
+- Byte/native compile smoke passes.
+- Interactive startup checklist passes.
+
+* Risks And Mitigations
+
+** Risk: Breaking muscle-memory keybindings
+
+Mitigation:
+
+- Change key registration mechanics before changing bindings.
+- Add keymap resolution tests for important prefixes.
+- Keep a per-batch manual keybinding checklist.
+
+** Risk: Lazy-loaded packages miss early hook setup
+
+Mitigation:
+
+- Prefer =use-package :hook= and =:mode= over ad hoc lazy command bodies for mode
+ packages.
+- Add tests that inspect hook contents where possible.
+- Smoke-test opening representative files.
+
+** Risk: Daily workflows silently stop starting
+
+Mitigation:
+
+- Distinguish "safe default" from "local opt-in" for workflows like calendar
+ sync.
+- Use ignored/local config files for private eager opt-ins.
+- Report missing config clearly.
+
+** Risk: Batch tests differ from interactive startup
+
+Mitigation:
+
+- Guard timers/process/network work with =noninteractive= only when that is the
+ intended distinction.
+- Add at least one interactive checklist per migration batch.
+
+** Risk: Refactor becomes too broad
+
+Mitigation:
+
+- One batch, one module family.
+- Do not mix dependency fixes, keybinding redesign, and package lazy-loading in
+ the same commit unless tightly coupled.
+- Keep rollback easy by preserving user-facing commands and using wrappers.
+
+* Implementation Backlog
+
+The project in =todo.org= should remain the source of task state. This design
+supports these implementation tickets:
+
+1. Classify modules by role and startup requirement.
+2. Add explicit module dependencies before changing load order.
+3. Centralize custom keymap registration.
+4. Defer low-risk optional modules.
+5. Defer heavy document/media/integration modules.
+6. Revisit programming module LSP/tree-sitter ownership.
+7. Revisit Org module cache/timer and optional extension loading.
+8. Retire or rewrite stale =init.el= comments.
+9. Create a sibling utility consolidation project with an inventory pass and
+ first helper extractions.
+
+* Open Questions
+
+- Should =config-utilities= remain eager because debug commands are useful
+ during startup work, or should it become command-loaded after this project?
+- Should local/private opt-ins share one file, or should modules keep
+ workflow-specific local files such as =calendar-sync.local.el=?
+- Should the module inventory become machine-readable for validation, or is an
+ org table enough? Decide during Phase 1 based on inventory authoring
+ experience.
+- Should =init.el= ultimately become declarative sections plus an explicit
+ startup contract list?
+
+* Next Steps
+
+1. Use this document as the reference for the =Classify modules by role and
+ startup requirement= task.
+2. Build the first inventory directly from the module table above, correcting
+ category guesses while inspecting each file.
+3. Do not defer a module until its direct runtime dependencies are explicit.
+4. Implement keymap registration before deferring feature modules that currently
+ mutate =cj/custom-keymap= at top level.
+5. Create the sibling utility consolidation project before Phase 2 work begins,
+ so duplicated helpers found during dependency cleanup have a clear place to
+ land.
diff --git a/docs/specs/keybinding-console-safety-spec-doing.org b/docs/specs/keybinding-console-safety-spec-doing.org
new file mode 100644
index 000000000..4a1dec813
--- /dev/null
+++ b/docs/specs/keybinding-console-safety-spec-doing.org
@@ -0,0 +1,943 @@
+:PROPERTIES:
+:ID: 540bf06b-16b8-46c6-b459-c40d1b9c795d
+:STATUS: doing
+:END:
+#+TITLE: Keymap Consolidation — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-12
+
+* Metadata
+| Status | doing |
+|----------+--------------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+--------------------------------------------------------------------|
+| Reviewer | TBD (multi-reviewer cycle) |
+|----------+--------------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org]] — "M-S- launcher keys" task (to be reclassified, Phase 0) |
+|----------+--------------------------------------------------------------------|
+
+* Summary
+
+Some commonly-used window/layout commands are bound to =M-S-<letter>= chords that only work in GUI frames, via a fragile =key-translation-map= layer that already caused a regression.
+
+The primary work consolidates the common commands under the =cj/custom-keymap= personal keymap and retires the fragile translation layer — independent of any prefix choice. Console reachability is then a one-line, *optional* follow-on: bind that one keymap to a single console-safe prefix (a =Control=+key, or a free =M-<punctuation>=; candidates in Appendix C), used everywhere. Per Path 2 (2026-06-13), the work proceeds up to the point of assigning that prefix and stops there; the assignment is a deferred phase Craig takes when he picks the key.
+
+The aim: consolidate the common commands into one keymap and retire the translation block now, leaving a single, optional console-safe prefix to switch on later.
+
+* Problem / Context
+
+A subset of common commands is bound to =M-S-<letter>= chords (Meta + Shift + lowercase letter). Pressing Meta+Shift+e emits the event =M-E= (uppercase Meta), but the command is bound to =M-S-e=; the bridge between them is a =key-translation-map= entry that =modules/keyboard-compat.el= installs *only* in GUI frames (=env-gui-p=). So these chords are dead in terminal frames and dead in the Linux console.
+
+Craig does not use terminal or console Emacs often, but falls back to the console in emergencies (a broken graphical session). When common keys are unavailable there, the editor stops being usable for the emergency and he has to switch tools. For *uncommon* commands, =M-x= is an acceptable fallback; for *common* ones it is not.
+
+How each key family actually behaves across the three contexts (the facts the design turns on):
+
+| Context | Meta sent as | =M-S-e= (as bound) | =M-E= (uppercase Meta) | =C-;= |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+| GUI frame | native event | reached only via the | intercepted by the | works natively |
+| | | GUI translation map | translation map | |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+| Terminal emulator | ESC prefix | dead (keypress emits | works (ESC E), if no | works if the emulator |
+| (xterm-family) | | =M-E=, binding is on | translation intercepts | speaks |
+| | | =M-S-e=) | | modifyOtherKeys/kitty |
+| | | | | (recent Emacs |
+| | | | | auto-enables for |
+| | | | | xterm-family) |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+| Linux console | ESC prefix | dead (same reason) | works (ESC E) | DEAD — semicolon is not |
+| (TERM=linux) | | | | a control char; cannot |
+| | | | | be transmitted |
+|-------------------------+--------------+-------------------------+-------------------------+-------------------------|
+
+Three consequences: =M-S-e= is dead outside GUI by construction; =C-;= is solid in GUI, conditional in terminal emulators, and dead in the Linux console (so it cannot be the *only* home for console-critical commands); and =M-E= plus function keys and =C-c= sequences are transmittable everywhere, which is the material to build a console-safe path from.
+
+** The regression that triggered this
+
+Commit =4a1ecf64= "fixed" three launcher keys (=eww=/=elfeed=/=calibredb=) by rebinding them from =M-S-e/r/b= to =M-E/M-R/M-B=. It was wrong, and three review passes missed it because they all used =key-binding=, which consults keymaps only and ignores =key-translation-map=. The original audit "verified dead in the live daemon" with that blind check (false positive); the fix bound =M-E= but left the =M-E -> M-S-e= translation entry in place, so in GUI the keypress is rewritten to the now-unbound =M-S-e= and the launchers break on the next restart; and the new test asserted =(key-binding (kbd "M-E"))=, passing against a configuration broken at the keyboard. It only appears to work in the running daemon because the pre-fix binding is still loaded as stale state — the stale-daemon trap.
+
+The lesson is encoded into the acceptance criteria: real reachability is not =key-binding= when a translation map participates.
+
+* Goals and Non-Goals
+
+** Goals
+- Every *commonly used* command is reachable in GUI, terminal emulators, and the Linux console.
+- One canonical personal command surface, so console-reachability is solved once at the prefix level rather than per command.
+- Retire the =keyboard-compat.el= =M-uppercase -> M-S-lowercase= translation block, the root of the fragility.
+- Keep daily ergonomics: high-frequency commands keep a fast chord in GUI.
+
+** Non-Goals
+- Making *every* binding console-safe. Uncommon commands may live on =M-x= only.
+- A ground-up keymap redesign. This is about reachability and retiring one fragile mechanism.
+- Defeating the Linux virtual console's hard limits (it cannot transmit =C-;=, and Meta+Shift behaviour varies). The design routes around them.
+
+** Scope tiers
+- *v1 (primary — Phases 0–2):*
+ - revert =4a1ecf64= (Phase 0, unblocks the push);
+ - prune the forgotten keybindings Craig marks in Appendix D;
+ - migrate the common window/layout =M-S-= commands into =cj/custom-keymap=;
+ - drop the uncommon chords to =M-x=;
+ - retire the translation block;
+ - translation-aware tests.
+- *Deferred / optional (Phase 3):*
+ - bind =cj/custom-keymap= to a single console-safe prefix (D1/D3) once Craig picks the key — the console-reachability payoff, switched on later.
+- *Out of scope:*
+ - enabling modifyOtherKeys/kitty for terminal emulators (helps terminals, not the console; orthogonal).
+- *vNext:* auditing non-=M-S-= GUI-only chords (modified function keys, =C-+/C-==) for console behaviour.
+
+* Design
+
+Craig's first choice (review, 2026-06-12): instead of two prefixes, retrain muscle memory onto *one* console-safe prefix that works everywhere — a =Control= + home-row key that is lightly used or easily/intuitively rebound. =cj/custom-keymap= moves from =C-;= to that single prefix (=C-;= may stay bound during the transition since one keymap can carry many prefixes). The candidate list is Appendix C; the standout home-row candidate is =C-l=. The two-prefix design below is the documented fallback if no single prefix proves acceptable.
+
+The personal command surface is already a single keymap object, =cj/custom-keymap= (=modules/keybindings.el=), bound to =C-;=. The whole design rests on one Emacs fact: a keymap is an object and can be bound to more than one prefix. So console-reachability is a *prefix* problem, not a per-command problem.
+
+For a user (single-prefix path): you reach your personal commands with the one console-safe prefix in GUI, terminal, and console alike — same menu, same keys after the prefix, nothing per-context to remember. (Fallback two-prefix path: =C-;= in the GUI as today, plus a second console-safe prefix anywhere.)
+
+For the implementer: add one line — =(keymap-global-set "<console-safe-prefix>" cj/custom-keymap)= — and the entire tree in Appendix A becomes reachable through it; nothing per-command. The work then is to move the *common* console-dead commands (the window/layout =M-S-= subset, Appendix B) *into* =cj/custom-keymap= so they inherit that reachability, drop the *uncommon* =M-S-= chords to =M-x=, and delete the now-unused translation block. High-frequency window commands additionally keep a fast chord so daily GUI use doesn't regress to a 3-key sequence (Decision D4).
+
+The console-dead common set is window/layout work, which has no =C-;= sub-prefix today, so v1 adds one (a new window sub-map; letter is Decision D5). The =C-c=/=C-h=/=C-z=/=C-x= and plain function-key bindings already work in the console and stay where they are.
+
+* Alternatives Considered
+
+** A — Revert 4a1ecf64 and keep the translation layer as the end state
+- Good, because it is the smallest change and restores correctness immediately.
+- Bad, because it keeps 18 keys on the GUI-only mechanism that already bit us and
+ leaves the console-dead problem unsolved.
+- Neutral, because the revert itself is still needed as Phase 0; this option just
+ stops there.
+
+** B — Migrate the whole family to direct uppercase-Meta, delete the translation block, no C-; move
+- Good, because it preserves every single-chord and =M-E= (ESC + uppercase) is
+ transmittable in GUI, terminal, and console alike.
+- Bad, because it bets the emergency-console guarantee on Meta+Shift behaving
+ cleanly on every console keyboard, which is probable but not certain, and it
+ gives the common commands no robust prefix-based fallback.
+- Neutral, because it still deletes the translation block (shared with the chosen
+ design) and could be layered onto the frequent-chord subset (see D4 Option B).
+
+** C — Enable an enhanced keyboard protocol (modifyOtherKeys / kitty) so C-; works in terminals
+- Good, because it makes =C-;= itself work in capable terminal emulators.
+- Bad, because it does nothing for the Linux virtual console (a hard limit), and
+ adds a terminal-capability dependency.
+- Neutral, because it is orthogonal and could be added later without conflicting.
+
+** Chosen — one map, two prefixes (consolidate common commands under C-;, add a console-safe alt prefix)
+- Good, because console-reachability is solved once at the prefix; it depends on
+ exactly one prefix working, and that prefix is chosen to be bulletproof.
+- Bad, because moved commands cost a muscle-memory transition, and a pure
+ sub-prefix path is 3 keys (mitigated by D4 for the frequent ones).
+- Neutral, because it still requires the revert (Phase 0) and the translation-
+ block deletion (shared with B).
+
+* Decisions [3/5]
+
+Each decision is a TODO task. It flips to DONE when Craig agrees with the call; if
+he doesn't, it stays TODO and the discussion continues under its =*** Discussion=
+child header.
+
+*Gate (Path 2).* The decisions split by which work they block. D2, D4, and D5 gate
+the *primary* work (Phases 0–2: revert, prune, consolidate, retire the translation
+block); the spec is implementation-ready for that work once those three are DONE. D1
+and D3 (the console-safe prefix) gate *only* the optional Phase 3 — they can stay
+TODO indefinitely without blocking the consolidation. So yes: the work proceeds all
+the way to the point of assigning the prefix and stops there, exactly as Craig asked,
+even if D1/D3 are never decided. The =[n/5]= cookie tracks the overall tally; full
+=ready= (including Phase 3) still needs all five.
+
+** DONE D1 — One map, one console-safe prefix (single-prefix primary; two-prefix fallback)
+CLOSED: [2026-06-13 Sat 00:20]
+- Owner / by-when: Craig / review cycle
+- Context: the common console-dead commands need to be reachable in the console;
+ =C-;= alone is dead there; per-command console bindings would not scale.
+- Decision (revised): We keep =cj/custom-keymap= as the single personal surface.
+ *Primary (Craig's first choice):* rebind it to ONE console-safe prefix — a
+ =Control= + lightly-used home-row key (Appendix C; standout =C-l=) — used in GUI,
+ terminal, and console alike, retraining muscle memory off =C-;=. =C-;= may stay
+ bound during the transition. *Fallback:* if no single prefix is acceptable, bind
+ the map to both =C-;= (GUI) and one console-safe alternate prefix (D3).
+- Consequences: easier — one prefix to make console-safe, whole tree travels, and
+ the single-prefix path needs no per-context mnemonic; harder — every
+ console-critical command must actually live under =cj/custom-keymap= (so the
+ common =M-S-= set is still migrated in), and the single-prefix path costs a
+ full =C-;= → new-prefix muscle-memory transition.
+*** Discussion
+- Direction agreed by Craig 2026-06-12: single-prefix primary, two-prefix fallback.
+- Deferred by Craig 2026-06-13 (Path 2): the console-safe prefix becomes the optional
+ Phase 3, not part of the primary work. The consolidation (Phases 0–2) lands without
+ it, so D1 no longer blocks anything until Craig chooses to do Phase 3. It stays TODO
+ as the marker for "decide the prefix later." The phases are rewritten accordingly,
+ and the keybinding audit Craig asked for lives in Appendix D.
+
+** DONE D2 — Migrate only the common (window/layout) M-S- set; drop the uncommon to M-x
+CLOSED: [2026-06-13 Sat 00:22]
+- Owner / by-when: Craig / review cycle
+- Context: of the 18 =M-S-= commands, only window/layout control is plausibly
+ needed in an emergency console session; apps and one-off tools are not.
+- Decision: We will move the window/layout subset (=M-S-o/m/v/h/t/u/z=, and
+ =M-S-k= pending review) into =C-;=, and remove the other ten =M-S-= chords,
+ leaving those commands on =M-x=.
+- Consequences: easier — shrinks the translation block to nothing, focuses the
+ console surface on essentials; harder — the dropped commands lose a chord;
+ =show-kill-ring='s classification is a judgment call.
+*** Discussion
+- Not yet reviewed by Craig. Open: confirm the window/layout subset to migrate
+ (incl. =M-S-k= show-kill-ring's common/uncommon call) and that the other ten
+ drop to =M-x=. Flip to DONE on Craig's sign-off.
+
+
+** TODO D3 — The console-safe prefix (pick from Appendix C)
+- Owner / by-when: Craig / review cycle
+- Context: under D1's single-prefix primary, this prefix is THE personal-keymap
+ prefix; under the two-prefix fallback it is the second (alternate) binding. It
+ must transmit in the Linux console, where only =Control= + letter chords carry
+ (and TAB/RET/LF/ESC/DEL collisions and =C-g= are excluded). Full candidate
+ analysis is Appendix C.
+- Decision: For the single-prefix path, =C-l= is the standout (home-row,
+ console-safe, default =recenter-top-bottom= is light and trivially relocatable);
+ =C-q= / =C-o= / =C-t= are off-home-row runners-up. For the two-prefix fallback,
+ =C-c ;= (=C-c= transmits; =;= is a plain key; mnemonic mirror of =C-;=) stays the
+ recommendation. Craig picks the prefix.
+- Consequences: easier — solves console reachability for the whole tree at one
+ binding; harder — a single =Control=+letter prefix displaces its default command
+ (relocate =recenter-top-bottom= if =C-l=), and =C-l= is also bound to
+ =vertico-insert= inside the minibuffer (=selection-framework.el:42=) — minibuffer-
+ local, so no conflict with a global prefix, but worth noting.
+*** Discussion
+- Open: Craig picks the prefix. Recommendation =C-l= (only clean home-row option);
+ runners-up =C-q= / =C-o= / =C-t=. Flip to DONE on the pick. D1 closes with it.
+
+
+#+begin_src cj: comment
+it's not going to be C-l. That's too hard of a habit for me to kick right now. I'd rather go C-c ; altogether -- even in GUI -- than have C-l do the wrong thing when I hit it. We'll find something. But it's not decided yet. Change the status of this decision to waiting.
+#+end_src
+
+** DONE D4 — Fast-chord strategy for high-frequency window ops
+CLOSED: [2026-06-13 Sat 00:25]
+- Owner / by-when: Craig / review cycle
+- Context: =split-and-follow-right/below= and =undo-kill-buffer= are pressed
+ constantly; a 3-key =C-; <w> v= sequence is a real downgrade.
+- Decision: We will (Option A) keep a fast GUI chord for the frequent commands in
+ addition to their =C-;= entry, OR (Option B) bind them to direct uppercase-Meta
+ single chords and retire the translation block. Review picks.
+- Consequences: A — preserves speed, but the fast chord may itself be GUI-only
+ unless it is a function key; B — single chord works in all three contexts but
+ leans on console Meta+Shift.
+*** Discussion
+- Open: Craig picks Option A (keep fast GUI chord) vs Option B (direct
+ uppercase-Meta single chords). Note: if D3 lands a single console-safe prefix,
+ Option B's console rationale weakens. Flip to DONE on the pick.
+
+#+begin_src cj: comment
+we can simply revert
+#+end_src
+
+** TODO D5 — Window sub-prefix and apps disposition
+- Owner / by-when: Craig / review cycle
+- Context: window/layout has no =C-;= sub-prefix. Free single lowercase letters are
+ =i q u y z= (=g= is calendar, =h= is Hugo — both taken); most uppercase is free.
+ =C-; L= is reserved for the Pearl/Linear package — do NOT reuse it. The four apps
+ (=eww=/=elfeed=/=calibredb=/=wttrin=) could go to =M-x= or a launcher sub-prefix.
+
+ #+begin_src cj: comment
+ add a listing of the keybindings we're discussing. I don't know what the window/layout keybindings you're discussing. It's not shift arrow keys, is it?
+ #+end_src
+- Decision: We will add a window sub-prefix under =C-;= (letter TBD from the free
+ set). Apps: Craig decided the launcher commands get real keys under a launcher
+ sub-prefix (=e/f/b/w= leaves), NOT =M-x=. Sub-prefix letter TBD; the freed
+ =C-; a t= (ai-assistant toggle, see Phase 0) is one candidate location if the
+ apps belong nearer the AI tools. Both sub-prefix letters are Craig's pick.
+- Consequences: easier — groups window ops and launcher apps discoverably under
+ which-key, and the launcher apps inherit console reachability for free; harder —
+ spends two scarce top-level =C-;= letters from the small free set.
+*** Discussion
+- Apps half agreed by Craig 2026-06-12: launcher sub-prefix, not =M-x=. Open: the
+ window sub-prefix letter and the launcher sub-prefix letter, both from the free
+ set {=i q u y z=} + uppercase (NOT =L= — Pearl). Flip to DONE once both letters
+ are chosen.
+
+* Implementation phases
+
+Path 2 (Craig, 2026-06-13): Phase 0 is a *pure revert* that unblocks the held push; the migration follows, and the console-safe prefix is an *optional, deferred* phase. Everything proceeds up to the point of assigning the prefix (end of Phase 2) and stops there; Phase 3 is the optional assignment once Craig picks the prefix. So the consolidation does not wait on the prefix decision (D1/D3); only Phase 3 does.
+
+** Phase 0 — Revert the regression (unblocks the push)
+Revert =4a1ecf64= and nothing more: restore =M-S-e/r/b= in the three modules and delete the flawed test (=tests/test-launcher-meta-shift-keys.el=), leaving a clean, correct baseline. Reclassify the "M-S- launcher keys" task as not-a-bug — the keys worked via the GUI translation layer. This is the only step the held 12-commit stack needs before it can push. Per Path 2, the launchers get reverted to =M-S-= here and move to their new homes in Phase 2 — the accepted small throwaway (3 bindings) of not waiting on the full move-map.
+
+The flawed test asserts the launcher bindings with =key-binding= alone:
+
+#+begin_src emacs-lisp
+(should (eq (key-binding (kbd "M-E")) 'eww))
+(should (eq (key-binding (kbd "M-R")) 'cj/elfeed-open))
+(should (eq (key-binding (kbd "M-B")) 'calibredb))
+#+end_src
+
+=key-binding= consults keymaps only and ignores =key-translation-map=, so the test passes even though the GUI translation entry =M-E -> M-S-e= rewrites the keypress back to the now-unbound =M-S-e=. It cannot see the rewrite, so it certifies a configuration that is broken at the keyboard. Phase 2's translation-aware assertion replaces it.
+
+** Phase 1 — Audit and prune forgotten keybindings (Appendix D)
+Appendix D inventories every keybinding Craig has set outside the =C-;= tree and the =M-S-= family — the place to catch chords set-and-forgotten. Craig checks the boxes for the bindings to retire; remove those. Independent cleanup, and a good moment to clear cruft before the migration. Tree working.
+
+** Phase 2 — Consolidate: migrate the common set, retire the translation block
+The primary deliverable, needing *no* console-safe-prefix decision. Migrate the window/layout =M-S-= subset into =cj/custom-keymap= under a new window sub-prefix (D5); add the launcher sub-prefix (D5) with the =eww=/=elfeed=/=calibredb=/=wttrin= leaves (freeing =C-; a t= — the =cj/toggle-gptel= ai-assistant toggle, =ai-config.el:541=, unfinished and far less used than the =ai-term= F9 launcher — if the letter is tight); apply the fast-chord strategy (D4); drop the ten uncommon =M-S-= chords to =M-x= (D2); delete =keyboard-compat.el='s translation block and its hook (keep the arrow-key =input-decode-map= setup); add the translation-aware tests (see Acceptance criteria) and update the docs. At the end of Phase 2 the work is "done" per Craig's stop point. Tree working.
+
+** Phase 3 — (OPTIONAL, deferred) Bind the console-safe prefix
+Only once Craig picks the prefix (D1/D3, Appendix C). Bind =cj/custom-keymap= to it — =(keymap-global-set "<prefix>" cj/custom-keymap)= — and if the pick is =C-l=, relocate its default =recenter-top-bottom= first. This is the console-reachability payoff: the whole tree becomes reachable in =emacs -nw= and the Linux console through one prefix. Verify in a *fresh* session, not the live daemon. May be deferred indefinitely; the consolidation stands on its own without it.
+
+* Acceptance criteria
+- [ ] The whole =cj/custom-keymap= tree is reachable in a GUI frame, an =emacs -nw= xterm-family terminal, and the Linux virtual console via the alt prefix.
+- [ ] The final "common" commands are reachable in all three contexts.
+- [ ] =keyboard-compat.el='s translation block is gone; no command depends on it.
+- [ ] For any chord claimed to run command X, tests assert BOTH =(key-binding (kbd CHORD))= AND =(lookup-key key-translation-map (kbd CHORD))= are consistent (the latter =nil=, or pointing where intended). =key-binding= alone is insufficient — it is what let =4a1ecf64= through.
+- [ ] Reachability is verified in a *fresh* frame/session, not the live daemon (the stale-daemon trap masks results).
+- [ ] =make test= fully green (the 4 pre-existing =test-dupre-theme= failures are tracked separately and out of scope).
+
+* Readiness dimensions
+- Data model & ownership: keybindings are user-authored code in =modules/=;
+ =cj/custom-keymap= is the owned surface. Nothing generated/cached/remote;
+ nothing persists.
+- Errors, empty states & failure: N/A — a missing command symbol surfaces as a
+ load-time =void-function=, caught by byte-compile and the launch smoke test.
+- Security & privacy: N/A — no credentials or sensitive data.
+- Observability: which-key shows each prefix's menu; =C-h k= / =describe-bindings=
+ report the live binding; the translation-aware test reports reachability.
+- Performance & scale: N/A — keymap lookup is constant-time; one extra prefix
+ binding has no measurable cost.
+- Reuse & lost opportunities: reuse Emacs's native multi-prefix keymap binding
+ (one keymap object, two prefix keys) instead of duplicating bindings; reuse
+ which-key and the existing =cj/register-prefix-map= / =cj/register-command=
+ helpers. Deletes (does not wrap) the bespoke translation layer.
+- Architecture fit & weak points: integration points are =keybindings.el=
+ (=cj/custom-keymap=, the register helpers), =keyboard-compat.el= (translation
+ block to delete; arrow-key decode to keep), and the per-module =:bind= /
+ register calls for the migrated commands. Weak point: the stale-daemon trap can
+ mask whether a change actually works — mitigated by verifying in a fresh
+ =-nw=/console session (acceptance criterion).
+- Config surface: the console-safe alt prefix (D3) and the window sub-prefix
+ letter (D5) are the only new knobs; both are constants set once in config.
+- Documentation plan: update the =keyboard-compat.el= header (it documents the
+ retired translation table); note the moved/dropped keys wherever keybindings
+ are documented. No user-facing migration doc beyond that.
+- Dev tooling: existing =make test= / byte-compile / launch smoke cover it; the
+ new translation-aware assertion is an ERT test like the others.
+- Rollout, compatibility & rollback: user-facing keybinding change; rollback is
+ =git revert=. No persisted data, no public API, no external state. The only
+ compatibility cost is Craig's muscle memory for the moved/dropped keys —
+ a transition note, not a migration.
+- External APIs & deps: N/A — no external APIs; no new dependencies.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Muscle-memory disruption* for moved/dropped keys. Dodge: keep fast chords for the highest-frequency commands (D4); accept =M-x= only for genuinely uncommon ones.
+- *Console Meta+Shift uncertainty* if D4 Option B is chosen. Dodge: the prefix path (D1/D3) does not depend on it, so the emergency guarantee holds regardless of the fast-chord choice.
+- *Stale-daemon trap* masking test results — the exact failure mode behind the regression. Dodge: the acceptance criteria mandate verification in a fresh frame/session and a translation-aware assertion.
+
+* References / Appendix
+
+** Appendix A — Full C-; keybinding tree (live, 2026-06-12)
+
+Dumped from the running daemon by walking =cj/custom-keymap= recursively.
+Format: chord — command — what it does.
+
+*** Top-level leaves (directly on C-;)
+- C-; ) — cj/jump-to-matching-paren — jump to the matching paren
+- C-; / — cj/replace-fraction-glyphs — replace 1/2-style fractions with glyphs
+- C-; ? — cj/flycheck-list-errors — list flycheck errors for the buffer
+- C-; A — align-regexp — align region by a regexp
+- C-; B — cj/choose-browser — pick the default browser
+- C-; f — cj/format-region-or-buffer — format region or whole buffer
+- C-; k — cj/org-babel-toggle-confirm — toggle the org-babel eval confirmation
+- C-; P — cj/projectile-reset-cmds — reset projectile's cached project commands
+- C-; SPC — cj/switch-to-previous-buffer — toggle to the previous buffer
+- C-; T — cj/telega — open Telegram (telega)
+- C-; | — display-fill-column-indicator-mode — toggle the fill-column rule
+- C-; # c — cj/count-characters-buffer-or-region — count characters
+- C-; # w — cj/count-words-buffer-or-region — count words
+
+*** C-; ! — System commands
+- C-; ! ! — cj/system-command-menu — the system-command transient menu
+- C-; ! e — cj/system-cmd-restart-emacs — restart Emacs
+- C-; ! E — cj/system-cmd-exit-emacs — exit Emacs
+- C-; ! l — cj/system-cmd-lock — lock the screen
+- C-; ! L — cj/system-cmd-logout — log out of the session
+- C-; ! r — cj/system-cmd-reboot — reboot
+- C-; ! s — cj/system-cmd-shutdown — shut down
+- C-; ! S — cj/system-cmd-suspend — suspend
+
+*** C-; a — AI / gptel
+- C-; a . — cj/gptel-add-this-buffer — add current buffer to the gptel context
+- C-; a A — cj/gptel-autosave-toggle — toggle conversation autosave
+- C-; a b — cj/gptel-browse-conversations — browse saved conversations
+- C-; a B — cj/gptel-switch-backend — switch the LLM backend
+- C-; a c — cj/gptel-context-clear — clear the gptel context
+- C-; a d — cj/gptel-delete-conversation — delete a saved conversation
+- C-; a f — cj/gptel-add-file — add a file to the context
+- C-; a l — cj/gptel-load-conversation — load a saved conversation
+- C-; a m — cj/gptel-change-model — change the model
+- C-; a M — gptel-menu — the gptel transient menu
+- C-; a p — gptel-system-prompt — edit the system prompt
+- C-; a q — cj/gptel-quick-ask — quick one-off ask
+- C-; a r — cj/gptel-rewrite-with-directive — rewrite region with a directive
+- C-; a R — cj/gptel-rewrite-redo-with-different-directive — redo rewrite, new directive
+- C-; a s — cj/gptel-save-conversation — save the conversation
+- C-; a t — cj/toggle-gptel — toggle the gptel chat buffer
+- C-; a x — cj/gptel-clear-buffer — clear the chat buffer
+
+*** C-; b — Buffer & file operations
+- C-; b <arrows> — cj/window-resize-sticky — sticky window resize (arrow keys)
+- C-; b b — cj/clear-to-bottom-of-buffer — clear from point to end
+- C-; b c b — cj/copy-to-bottom-of-buffer — copy point-to-end
+- C-; b c t — cj/copy-to-top-of-buffer — copy point-to-start
+- C-; b c w — cj/copy-whole-buffer — copy the whole buffer
+- C-; b d — cj/delete-buffer-and-file — delete the buffer and its file
+- C-; b D — cj/diff-buffer-with-file — diff buffer against its file on disk
+- C-; b e — eval-buffer — eval the buffer
+- C-; b E — cj/view-email-in-buffer — view the buffer as email
+- C-; b g — revert-buffer — revert from disk
+- C-; b k — cj/kill-buffer-and-window — kill buffer and close its window
+- C-; b K — cj/kill-other-window-buffer — kill the other window's buffer
+- C-; b l — cj/copy-link-to-buffer-file — copy an org link to the file
+- C-; b m — cj/move-buffer-and-file — move/rename buffer + file
+- C-; b n — cj/copy-buffer-name — copy the buffer name
+- C-; b o — cj/xdg-open — open the file with the system handler
+- C-; b O — cj/open-this-file-with — open with a chosen program
+- C-; b p — cj/copy-buffer-source-as-kill — copy buffer source
+- C-; b P — cj/print-buffer-ps — print the buffer (PostScript)
+- C-; b r — cj/rename-buffer-and-file — rename buffer + file
+- C-; b s — mark-whole-buffer — select all
+- C-; b S — write-file — write/save-as
+- C-; b t — cj/clear-to-top-of-buffer — clear from start to point
+- C-; b w — cj/view-buffer-in-eww — render the buffer in EWW
+- C-; b x — erase-buffer — erase the buffer
+
+*** C-; c — Case
+- C-; c l — cj/downcase-dwim — downcase (dwim)
+- C-; c t — cj/title-case-region — title-case the region
+- C-; c u — cj/upcase-dwim — upcase (dwim)
+
+*** C-; C — Comments
+- C-; C - — cj/comment-hyphen — hyphen divider comment
+- C-; C b — cj/comment-box — boxed comment
+- C-; C c — cj/comment-inline-border — inline bordered comment
+- C-; C d — cj/delete-buffer-comments — delete all comments in the buffer
+- C-; C h — cj/comment-heavy-box — heavy box comment
+- C-; C n — cj/comment-block-banner — block banner comment
+- C-; C p — cj/comment-padded-divider — padded divider comment
+- C-; C r — cj/comment-reformat — reformat a comment
+- C-; C s — cj/comment-simple-divider — simple divider comment
+- C-; C u — cj/comment-unicode-box — unicode box comment
+
+*** C-; d — Date / time insertion
+- C-; d d — cj/insert-sortable-date — insert YYYY-MM-DD
+- C-; d D — cj/insert-readable-date — insert a human-readable date
+- C-; d r — cj/insert-readable-date-time — readable date + time
+- C-; d s — cj/insert-sortable-date-time — sortable date + time
+- C-; d t — cj/insert-sortable-time — sortable time
+- C-; d T — cj/insert-readable-time — readable time
+
+*** C-; D — Org-drill (flashcards)
+- C-; D c — cj/drill-capture — capture a drill question
+- C-; D e — cj/drill-edit — open a drill file to edit
+- C-; D f — cj/drill-this-file — drill the current file
+- C-; D r — cj/drill-refile — refile into a drill file
+- C-; D R — org-drill-resume — resume a drill session
+- C-; D s — cj/drill-start — start a drill session
+
+*** C-; e — Email (mu4e)
+- C-; e s — cj/mu4e-save-attachment-here — save attachment to current dir
+- C-; e S — cj/mu4e-save-all-attachments — save all attachments
+- C-; e m — cj/mu4e-save-some-attachments — save selected attachments
+- C-; e {c,d,g} {i,l,s,u} — mu4e maildir searches: account {c=cmail, d=dmail,
+ g=gmail} x view {i=inbox, l=large >5M, s=starred/flagged, u=unread}
+
+*** C-; E — ERC (IRC)
+- C-; E b — cj/erc-switch-to-buffer-with-completion — switch ERC buffer
+- C-; E c — cj/erc-join-channel-with-completion — join a channel
+- C-; E C — cj/erc-connect-server-with-completion — connect to a server
+- C-; E l — cj/erc-connected-servers — list connected servers
+- C-; E q — erc-part-from-channel — leave a channel
+- C-; E Q — erc-quit-server — quit a server
+
+*** C-; g — Calendar sync (Google Calendar)
+- C-; g s — calendar-sync-now — sync now
+- C-; g S — calendar-sync-start — start auto-sync
+- C-; g x — calendar-sync-stop — stop auto-sync
+- C-; g t — calendar-sync-toggle — toggle auto-sync
+- C-; g i — calendar-sync-status — sync status
+
+*** C-; h — Hugo (website/blog)
+- C-; h n — cj/hugo-new-post — new post
+- C-; h d — cj/hugo-open-draft — open a draft
+- C-; h D — cj/hugo-toggle-draft — toggle a post's draft flag
+- C-; h e — cj/hugo-export-post — export a post
+- C-; h p — cj/hugo-preview — preview the site
+- C-; h P — cj/hugo-publish — publish the site
+- C-; h o — cj/hugo-open-blog-dir — open the blog dir in Emacs
+- C-; h O — cj/hugo-open-blog-dir-external — open the blog dir externally
+
+*** C-; j — Jump to files
+- C-; j c — cj/jump-to-contacts ; C-; j g — cj/jump-to-gcal
+- C-; j i — cj/jump-to-inbox ; C-; j I — cj/jump-to-emacs-init
+- C-; j m — cj/jump-to-macros ; C-; j n — cj/jump-to-reading-notes
+- C-; j r — cj/jump-to-reference ; C-; j s — cj/jump-to-schedule
+- C-; j w — cj/jump-to-webclipped
+
+*** C-; L — Pearl (Linear tickets) [RESERVED — do not reuse]
+- C-; L … — pearl-prefix-map — Pearl/Linear ticket commands (lazy sub-map)
+- =C-; L= is reserved as the Pearl (Linear integration) leader key. Sub-prefix
+ letter picks (D5) must avoid it.
+
+*** C-; l — Line & paragraph
+- C-; l c — duplicate line/region (comment variant) ; C-; l d — cj/duplicate-line-or-region
+- C-; l j — cj/join-line-or-region ; C-; l J — cj/join-paragraph
+- C-; l r — cj/remove-lines-containing ; C-; l R — cj/remove-duplicate-lines-region-or-buffer
+- C-; l u — cj/underscore-line
+
+*** C-; m — Music (EMMS)
+- C-; m m — cj/music-playlist-toggle ; C-; m M — cj/music-playlist-show
+- C-; m SPC — emms-pause ; C-; m s — emms-stop
+- C-; m n — cj/music-next ; C-; m p — cj/music-previous
+- C-; m a — cj/music-fuzzy-select-and-add ; C-; m g — emms-playlist-mode-go
+- C-; m r — emms-toggle-repeat-playlist ; C-; m t — emms-toggle-repeat-track
+- C-; m x — cj/music-toggle-consume ; C-; m z — emms-toggle-random-playlist
+- C-; m Z — emms-shuffle ; C-; m R — cj/music-create-radio-station
+
+*** C-; M — Signal (signel)
+- C-; M m — cj/signel-message — message a contact
+- C-; M s — cj/signel-message-self — note to self
+- C-; M SPC — cj/signel-connect — start/connect the daemon
+- C-; M d — signel-dashboard — the Signal dashboard
+- C-; M q — signel-stop — stop the daemon
+
+*** C-; n — Org-noter
+- C-; n t — cj/org-noter-start — start noter on the document
+- C-; n n — cj/org-noter-insert-note-dwim — insert a note (dwim)
+
+*** C-; o — Ordering / text transforms
+- C-; o a — cj/arrayify ; C-; o j — cj/arrayify-json ; C-; o p — cj/arrayify-python
+- C-; o u — cj/unarrayify ; C-; o l — cj/listify ; C-; o L — cj/comma-separated-text-to-lines
+- C-; o A — cj/alphabetize-region ; C-; o r — cj/reverse-lines ; C-; o n — cj/number-lines
+- C-; o q — cj/toggle-quotes ; C-; o o — cj/org-sort-by-todo-and-priority
+
+*** C-; p — reveal.js presentations
+- C-; p n — cj/reveal-new ; C-; p h — cj/reveal-insert-header ; C-; p H — cj/reveal-remove-headers
+- C-; p e — cj/reveal-export ; C-; p SPC — cj/reveal-present
+- C-; p p — cj/reveal-preview-start ; C-; p s — cj/reveal-preview-stop
+
+*** C-; r — Recording (audio/video)
+- C-; r a — cj/audio-recording-toggle ; C-; r v — cj/video-recording-toggle
+- C-; r s — cj/recording-quick-setup ; C-; r S — cj/recording-select-devices
+- C-; r d — cj/recording-list-devices ; C-; r l — cj/recording-adjust-volumes
+- C-; r w — cj/recording-show-active-audio
+- C-; r t b/m/s — cj/recording-test-both / -mic / -monitor
+
+*** C-; R — restclient
+- C-; R n — cj/restclient-new-buffer ; C-; R o — cj/restclient-open-file
+
+*** C-; s — Enclose / surround / indent
+- C-; s s — cj/surround-word-or-region ; C-; s u — cj/unwrap-word-or-region
+- C-; s w — cj/wrap-word-or-region ; C-; s i — cj/indent-lines-in-region-or-buffer
+- C-; s d — cj/dedent-lines-in-region-or-buffer ; C-; s a — cj/append-to-lines-in-region-or-buffer
+- C-; s p — cj/prepend-to-lines-in-region-or-buffer
+- C-; s I — change-inner ; C-; s O — change-outer
+
+*** C-; t — Test runner
+- C-; t r — cj/test-run-smart ; C-; t R — cj/test-run-all ; C-; t . — cj/run-test-at-point
+- C-; t a — cj/test-focus-add ; C-; t b — cj/test-focus-add-this-buffer-file
+- C-; t c — cj/test-focus-clear ; C-; t v — cj/test-view-focused
+- C-; t L — cj/test-load-all ; C-; t t — cj/test-toggle-mode
+
+*** C-; v — Version control (git / forge)
+- C-; v c — cj/git-clone-clipboard-url ; C-; v d — cj/goto-git-gutter-diff-hunks
+- C-; v t — cj/git-timemachine ; C-; v f — forge-pull ; C-; v r — forge-list-pullreqs
+- C-; v i c — cj/forge-create-issue ; C-; v i l — forge-list-issues
+
+*** C-; w — Whitespace
+- C-; w c — cj/collapse-whitespace-line-or-region ; C-; w d — cj/delete-all-whitespace
+- C-; w l — cj/delete-blank-lines-region-or-buffer ; C-; w 1 — cj/ensure-single-blank-line
+- C-; w r — cj/remove-leading-trailing-whitespace ; C-; w - — cj/hyphenate-whitespace-in-region
+- C-; w t — untabify ; C-; w T — tabify
+
+*** C-; x — Terminal (ghostel)
+- C-; x t — cj/term-toggle ; C-; x N — ghostel (new) ; C-; x c — cj/term-copy-mode-dwim
+- C-; x h — cj/term-tmux-history ; C-; x l — ghostel-clear-scrollback
+- C-; x n — ghostel-next-prompt ; C-; x p — ghostel-previous-prompt
+- C-; x q — ghostel-send-next-key
+
+** Appendix B — The M-S- family (18 keys)
+
+All bound as =M-S-<letter>= and reachable in GUI only, via the
+=keyboard-compat.el= translation layer. Format: chord — command — what it does —
+source module.
+
+- M-S-o — cj/kill-other-window — kill the other window's buffer and close it — undead-buffers.el
+- M-S-m — cj/kill-all-other-buffers-and-windows — close all other windows, kill their buffers — undead-buffers.el
+- M-S-y — yank-media — paste an image/media object from the clipboard — keybindings.el
+- M-S-f — fontaine-set-preset — switch the font preset — font-config.el
+- M-S-w — wttrin — show the weather report — weather-config.el
+- M-S-e — eww — open the EWW web browser — eww-config.el
+- M-S-l — cj/switch-themes — select/cycle the theme — ui-theme.el
+- M-S-r — cj/elfeed-open — open the Elfeed RSS reader — elfeed-config.el
+- M-S-v — cj/split-and-follow-right — split window right and move focus there — ui-navigation.el
+- M-S-h — cj/split-and-follow-below — split window below and move focus there — ui-navigation.el
+- M-S-t — toggle-window-split — toggle horizontal/vertical split — ui-navigation.el
+- M-S-z — cj/undo-kill-buffer — reopen the most-recently-killed file buffer — ui-navigation.el
+- M-S-u — winner-undo — undo the last window-configuration change — ui-navigation.el
+- M-S-d — dwim-shell-commands-menu — DWIM shell-command menu on marked files — dwim-shell-config.el
+- M-S-i — edit-indirect-region — edit the region in an indirect buffer — text-config.el
+- M-S-c — time-zones — show the world-clock / time-zones view — chrono-tools.el
+- M-S-b — calibredb — open the Calibre ebook library — calibredb-epub-config.el
+- M-S-k — show-kill-ring — browse the kill ring — show-kill-ring.el
+
+Note: =4a1ecf64= (in-flight, reverted in Phase 0) currently leaves
+=eww=/=elfeed=/=calibredb= mis-bound to =M-E=/=M-R=/=M-B=; the table lists the
+intended/original =M-S-= bindings.
+
+** Appendix C — Console-safe single-prefix candidates (D1/D3)
+
+Craig's first choice (D1) is one =Control=+key prefix that works in GUI, terminal,
+and the Linux console, ideally a lightly-used home-row key. Console transmittability
+is the gate. Two classes of chord transmit in =TERM=linux=:
+
+1. =Control= + letter (ASCII 1–26). Several collide with terminal control characters
+ and so cannot serve as a distinct prefix — =C-i=/TAB, =C-j=/LF, =C-m=/RET,
+ =C-[=/ESC, =C-h=/DEL — and =C-g= (=keyboard-quit=) is sacred and excluded.
+2. =Meta= + key, which the console sends as an *ESC prefix* (=M-x= = ESC then x).
+ This is why the Problem table above shows =M-E= working as "ESC E" in the console.
+ So a plain =M-<key>= prefix is console-safe too — and unlike the broken =M-S-=
+ family, an unshifted =M-<key>= binds directly with no =key-translation-map= in the
+ path. The catch is finding a free one: the Meta namespace is crowded (Appendix D
+ shows =M-*=, =M-+=, =M-#=, =M-P=, =M-t=, and the whole =M-g=/=M-s=/=M-e=/=M-r=
+ consult family taken), so a free Meta prefix would be punctuation (=M-\\=, =M-/=…),
+ not a letter, and it carries the usual ESC-prefix timing caveat in terminals.
+
+=Control= + *non-letter* punctuation (=C-;=, =C-'=, =C-.=…) does NOT transmit in the
+console — the character isn't a control code. So =C-'= is a non-starter on two counts:
+dead in the console like =C-;=, and already bound (=cj/flyspell-then-abbrev=, globally
+at =flyspell-and-abbrev.el:253= and in =org-mode-map= at =:258=). =Control=+letter
+(the table below) stays the cleanest path; a free =M-<punctuation>= is the viable
+runner-up class if Craig prefers Meta.
+
+| Candidate | Home-row | Console-safe | Default binding | Verdict + note |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-l | yes | yes | recenter-top-bottom | TOP. Home-row, light default, |
+| | | | | trivially relocated. Also |
+| | | | | vertico-insert in the minibuffer |
+| | | | | (selection-framework.el:42) — |
+| | | | | minibuffer-local, no global |
+| | | | | conflict. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-q | no | yes | quoted-insert | Strong runner-up. Very light |
+| | | | | default; trivial rebind. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-o | no | yes | open-line | Strong runner-up. Light default; |
+| | | | | easy rebind. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-t | no | yes | transpose-chars | Strong runner-up. Light default; |
+| | | | | easy rebind. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-k | yes | yes | kill-line | Possible. Home-row, but kill-to-eol |
+| | | | | is muscle memory — medium retrain |
+| | | | | friction. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-s | yes | yes | cj/consult-line-or-repeat | Possible, but already a useful |
+| | | | (selection-framework.el:265) | rebind; using it as a prefix |
+| | | | | reverses that. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-a | yes | yes | move-beginning-of-line | Reject. Essential editing reflex. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-d | yes | yes | delete-char | Reject. Essential. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-f | yes | yes | forward-char | Reject. Essential. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-h | yes | collides (DEL) | help-command | Reject. Console DEL collision; help |
+| | | | | is frequent. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-j | yes | collides (LF) | newline | Reject. LF control char; cannot |
+| | | | | transmit distinctly. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-g | yes | sacred | keyboard-quit | Reject. Universal escape; never |
+| | | | | reuse. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-z | no | yes | suspend-frame (live prefix; C-z F = | Reject. Already an extended prefix. |
+| | | | fonts, font-config.el:300) | |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| C-' | no | no | cj/flyspell-then-abbrev | Reject. Punctuation — dead in the |
+| | | | (flyspell-and-abbrev.el:253) | console like C-;; and already bound |
+| | | | | (also org-mode-map :258). |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+| M-<punct> | n/a | yes (ESC-prefix) | — (Meta namespace crowded; see | Viable runner-up class. Console-safe |
+| | | | Appendix D) | via ESC-prefix, no translation |
+| | | | | layer, distinct from the broken |
+| | | | | M-S-. Needs a free M-punctuation |
+| | | | | (M-\\, M-/); ESC-timing caveat in |
+| | | | | terminals. |
+|-----------+----------+------------------+-------------------------------------+--------------------------------------|
+
+Recommendation: =C-l= is the single best fit — the only clean home-row option (every
+other home-row letter is essential, a collision, sacred, or already repurposed),
+console-safe, and its default =recenter-top-bottom= is light and trivially relocated.
+=C-q= / =C-o= / =C-t= are equally console-safe and lightly bound if Craig prefers to
+keep all home-row defaults; they cost a right-hand reach off home row. If Craig would
+rather a Meta prefix, a free =M-<punctuation>= (=M-\\=, =M-/=) is the viable runner-up
+class — console-safe via ESC-prefix and free of the translation layer — at the cost of
+the ESC-timing caveat. =C-'= is out (console-dead and already taken). Craig picks.
+
+** Appendix D — Personal keybindings set outside C-; (audit for pruning)
+
+Every keybinding Craig has set *outside* the =C-;= tree (Appendix A) and the =M-S-=
+family (Appendix B), grouped by context. Check a box to mark that binding — or a
+whole group — for removal in Phase 1. Boxes start unchecked; Craig marks them.
+Inventoried 2026-06-13. Some =:bind (:map …)= package-integration maps (lsp-mode,
+c-mode-base, python-ts, json-ts, outline-minor, magit-blame, quick-sdcv, cj/vc-map)
+have large package-managed binding lists not enumerated here.
+
+- [ ] Global bindings
+ - [ ] C-+ — text-scale-increase — (font-config.el:306)
+ - [ ] C-= — text-scale-increase — (font-config.el:307)
+ - [ ] C-_ — text-scale-decrease — (font-config.el:308)
+ - [ ] C-- — text-scale-decrease — (font-config.el:309)
+ - [ ] C-x C-f — find-file — (keybindings.el:147)
+ - [ ] C-x \ — sort-lines — (keybindings.el:160)
+ - [ ] C-x u — undo-reminder-message — (keybindings.el:164)
+ - [ ] <escape> — keyboard-escape-quit — (keybindings.el:156)
+ - [ ] <remap> <capitalize-region> — cj/title-case-region — (custom-case.el:124)
+ - [ ] <remap> <kill-buffer> — cj/kill-buffer-or-bury-alive — (undead-buffers.el:55)
+ - [ ] <remap> <list-buffers> — ibuffer — (system-utils.el:147)
+ - [ ] <remap> <mouse-wheel-text-scale> — cj/disabled — (system-defaults.el:191)
+ - [ ] C-z — prefix map (suspend-frame replacement) — (keybindings.el:148)
+ - [ ] C-z F — cj/display-available-fonts — (font-config.el:300)
+ - [ ] C-h A — cj/local-arch-wiki-search — (help-utils.el:82)
+ - [ ] C-h D s — devdocs-search — (help-utils.el:40)
+ - [ ] C-h D b — devdocs-peruse — (help-utils.el:41)
+ - [ ] C-h D l — devdocs-lookup — (help-utils.el:42)
+ - [ ] C-h D i — devdocs-install — (help-utils.el:43)
+ - [ ] C-h D d — devdocs-delete — (help-utils.el:44)
+ - [ ] C-h D u — devdocs-update-all — (help-utils.el:45)
+ - [ ] C-h P — list-packages — (help-config.el:31)
+ - [ ] C-h i — cj/browse-info-files — (help-config.el:90)
+ - [ ] C-c b — cj/eval-buffer-with-confirmation-or-error-message — (system-utils.el:57)
+ - [ ] C-c C — cj/org-contacts-map prefix — (org-contacts-config.el:271)
+ - [ ] C-c d — cj/debug-config-keymap prefix — (config-utilities.el:28)
+ - [ ] C-c f — cj/flyspell-toggle — (flyspell-and-abbrev.el:252)
+ - [ ] C-c l — org-store-link — (org-config.el:58)
+ - [ ] C-c m — mu4e — (mail-config.el:125)
+ - [ ] C-c M — mouse-trap-mode — (mousetrap-mode.el:275)
+ - [ ] C-' — cj/flyspell-then-abbrev — (flyspell-and-abbrev.el:253)
+ - [ ] C-s — cj/consult-line-or-repeat — (selection-framework.el:265)
+ - [ ] M-* — calculator — (keybindings.el:152)
+ - [ ] M-+ — balance-windows — (ui-navigation.el:67)
+ - [ ] M-P — cj/check-for-open-work — (reconcile-open-repos.el:221)
+ - [ ] C-c n d — org-roam-dailies-map prefix — (org-roam-config.el:94)
+ - [ ] C-c n I — cj/org-roam-node-insert-immediate — (org-roam-config.el:131)
+- [ ] Function keys
+ - [ ] <f1> — cj/dashboard-only — (dashboard-config.el:158)
+ - [ ] <f3> — call-last-kbd-macro — (keyboard-macros.el:131)
+ - [ ] C-<f3> — cj/kbd-macro-start-or-end — (keyboard-macros.el:130)
+ - [ ] M-<f3> — cj/save-maybe-edit-macro — (keyboard-macros.el:132)
+ - [ ] s-<f3> — cj/open-macros-file — (keyboard-macros.el:133)
+ - [ ] <f4> — cj/f4-compile-and-run — (dev-fkeys.el:535)
+ - [ ] C-<f4> — cj/f4-compile-only — (dev-fkeys.el:536)
+ - [ ] M-<f4> — cj/f4-clean-rebuild — (dev-fkeys.el:537)
+ - [ ] S-<f4> — recompile — (dev-fkeys.el:538)
+ - [ ] <f6> — cj/f6-test-runner — (dev-fkeys.el:539)
+ - [ ] C-<f6> — cj/f6-current-file-tests — (dev-fkeys.el:540)
+ - [ ] S-<f5> (Python) — cj/python-mypy — (prog-python.el:103)
+ - [ ] S-<f5> (Shell) — cj/shell-run-shellcheck — (prog-shell.el:98)
+ - [ ] S-<f5> (Go) — cj/go-staticcheck — (prog-go.el:102)
+ - [ ] S-<f5> (C) — cj/disabled — (prog-c.el:158)
+ - [ ] S-<f6> (Python) — cj/python-debug — (prog-python.el:106)
+ - [ ] S-<f6> (Shell) — cj/disabled — (prog-shell.el:101)
+ - [ ] S-<f6> (Go) — cj/go-debug — (prog-go.el:105)
+ - [ ] S-<f6> (C) — gdb — (prog-c.el:161)
+ - [ ] <f7> — cj/coverage-report — (coverage-core.el:537)
+ - [ ] <f8> — cj/main-agenda-display — (org-agenda-config.el:418)
+ - [ ] C-<f8> — cj/todo-list-single-project — (org-agenda-config.el:269)
+ - [ ] M-<f8> — cj/todo-list-from-this-buffer — (org-agenda-config.el:283)
+ - [ ] s-<f8> — cj/todo-list-all-agenda-files — (org-agenda-config.el:244)
+ - [ ] <f9> — cj/ai-term — (ai-term.el:920)
+ - [ ] C-<f9> — cj/ai-term-pick-project — (ai-term.el:921)
+ - [ ] M-<f9> — cj/ai-term-close — (ai-term.el:922)
+ - [ ] C-S-<f9> — cj/ai-term-close — (ai-term.el:923)
+ - [ ] <f10> — cj/music-playlist-toggle — (music-config.el:910)
+ - [ ] C-<f10> — cj/server-shutdown — (system-utils.el:105)
+ - [ ] <f12> — cj/term-toggle — (term-config.el:383)
+ - [ ] C-<f12> — eshell-toggle — (eshell-config.el:161)
+- [ ] use-package :bind (global)
+ - [ ] C-c L — slime — (prog-lisp.el:151)
+ - [ ] C-c G — geiser-guile — (prog-lisp.el:172)
+ - [ ] C-h L — leetcode — (prog-training.el:35)
+ - [ ] C-h M — man — (help-config.el:49)
+ - [ ] C-h T — tldr — (help-utils.el:53)
+ - [ ] C-h W — wiki-summary — (help-utils.el:58)
+ - [ ] C-` — accent-company — (text-config.el:122)
+ - [ ] C-x M-f — sudo-edit — (system-utils.el:66)
+ - [ ] C-x g — magit-status — (vc-config.el:34)
+ - [ ] C-c s i — consult-yasnippet — (selection-framework.el:191)
+ - [ ] M-# — calendar — (chrono-tools.el:38)
+ - [ ] M-t — tmr-prefix-map — (chrono-tools.el:110)
+ - [ ] C-M-p — proced — (system-utils.el:183)
+- [ ] Vertico / selection framework
+ - [ ] C-h C-k — free-keys — (keybindings.el:129)
+ - [ ] C-j (vertico-map) — vertico-next — (selection-framework.el:40)
+ - [ ] C-k (vertico-map) — vertico-previous — (selection-framework.el:41)
+ - [ ] C-l (vertico-map) — vertico-insert — (selection-framework.el:42)
+ - [ ] RET (vertico-map) — vertico-exit — (selection-framework.el:43)
+ - [ ] C-RET (vertico-map) — vertico-exit-input — (selection-framework.el:44)
+ - [ ] M-RET (vertico-map) — minibuffer-force-complete-and-exit — (selection-framework.el:45)
+ - [ ] TAB (vertico-map) — minibuffer-complete — (selection-framework.el:46)
+- [ ] Consult (global)
+ - [ ] C-c h — consult-history — (selection-framework.el:64)
+ - [ ] C-x M-: — consult-complex-command — (selection-framework.el:66)
+ - [ ] C-x b — consult-buffer — (selection-framework.el:67)
+ - [ ] C-x 4 b — consult-buffer-other-window — (selection-framework.el:68)
+ - [ ] C-x 5 b — consult-buffer-other-frame — (selection-framework.el:69)
+ - [ ] C-x r b — consult-bookmark — (selection-framework.el:70)
+ - [ ] C-x p b — consult-project-buffer — (selection-framework.el:71)
+ - [ ] M-g e — consult-compile-error — (selection-framework.el:73)
+ - [ ] M-g f — consult-flymake — (selection-framework.el:74)
+ - [ ] M-g g — consult-goto-line — (selection-framework.el:75)
+ - [ ] M-g M-g — consult-goto-line — (selection-framework.el:76)
+ - [ ] M-g o — consult-outline — (selection-framework.el:77)
+ - [ ] M-g m — consult-mark — (selection-framework.el:78)
+ - [ ] M-g k — consult-global-mark — (selection-framework.el:79)
+ - [ ] M-g i — consult-imenu — (selection-framework.el:80)
+ - [ ] M-g I — consult-imenu-multi — (selection-framework.el:81)
+ - [ ] M-s d — consult-find — (selection-framework.el:83)
+ - [ ] M-s D — consult-locate — (selection-framework.el:84)
+ - [ ] M-s g — consult-grep — (selection-framework.el:85)
+ - [ ] M-s G — consult-git-grep — (selection-framework.el:86)
+ - [ ] M-s r — consult-ripgrep — (selection-framework.el:87)
+ - [ ] M-s l — consult-line — (selection-framework.el:88)
+ - [ ] M-s L — consult-line-multi — (selection-framework.el:89)
+ - [ ] M-s k — consult-keep-lines — (selection-framework.el:90)
+ - [ ] M-s u — consult-focus-lines — (selection-framework.el:91)
+ - [ ] M-s e — consult-isearch-history — (selection-framework.el:93)
+- [ ] Isearch / minibuffer search
+ - [ ] M-e (isearch-mode-map) — consult-isearch-history — (selection-framework.el:95)
+ - [ ] M-s e (isearch-mode-map) — consult-isearch-history — (selection-framework.el:96)
+ - [ ] M-s l (isearch-mode-map) — consult-line — (selection-framework.el:97)
+ - [ ] M-s L (isearch-mode-map) — consult-line-multi — (selection-framework.el:98)
+ - [ ] M-s (minibuffer-local-map) — consult-history — (selection-framework.el:101)
+ - [ ] M-r (minibuffer-local-map) — consult-history — (selection-framework.el:102)
+- [ ] PDF view mode
+ - [ ] M — pdf-view-midnight-minor-mode — (pdf-config.el:49)
+ - [ ] m — bookmark-set — (pdf-config.el:50)
+ - [ ] C-= — pdf-view-enlarge — (pdf-config.el:51)
+ - [ ] C-- — pdf-view-shrink — (pdf-config.el:52)
+ - [ ] C-c l — org-store-link — (pdf-config.el:53)
+ - [ ] z — cj/open-file-with-command zathura — (pdf-config.el:54)
+ - [ ] j — image-next-line — (pdf-config.el:56)
+ - [ ] k — image-previous-line — (pdf-config.el:57)
+ - [ ] <down> — image-next-line — (pdf-config.el:58)
+ - [ ] <up> — image-previous-line — (pdf-config.el:59)
+ - [ ] i — cj/org-noter-insert-note-dwim — (pdf-config.el:61)
+ - [ ] C-<down> — pdf-view-next-page-command + image-bob — (pdf-config.el:63)
+ - [ ] C-<up> — pdf-view-previous-page-command + image-eob — (pdf-config.el:65)
+- [ ] Ediff mode
+ - [ ] j (ediff-mode-map) — ediff-next-difference — (diff-config.el:54)
+ - [ ] k (ediff-mode-map) — ediff-previous-difference — (diff-config.el:55)
+- [ ] Org / org-related
+ - [ ] C-' (org-mode-map) — cj/flyspell-then-abbrev — (flyspell-and-abbrev.el:258)
+ - [ ] S-<mouse-1> (org-mouse-map) — cj/org-follow-link-at-mouse-same-window — (org-config.el:338)
+ - [ ] <mouse-3> (org-mouse-map) — cj/org-follow-link-at-mouse-same-window — (org-config.el:339)
+- [ ] Dired / dirvish
+ - [ ] G (dired-mode-map) — cj/deadgrep-here — (prog-general.el:277)
+ - [ ] M-D (dirvish-mode-map) — dwim-shell-commands-menu — (dwim-shell-config.el:934)
+ - [ ] + (dirvish-mode-map) — cj/music-add-dired-selection — (music-config.el:597)
+ - [ ] T (dired/dirvish-mode-map) — cj/transcribe-media-at-point — (transcription-config.el:463/467)
+ - [ ] <f11> (dirvish-mode-map) — dirvish-side — (dirvish-config.el:481)
+- [ ] Shell / terminal
+ - [ ] C-r (eshell-mode-map) — cj/eshell-history-search — (eshell-config.el:202)
+ - [ ] <up> (eshell-hist-mode-map) — previous-line — (eshell-config.el:99)
+ - [ ] <down> (eshell-hist-mode-map) — next-line — (eshell-config.el:100)
+- [ ] Ghostel terminal
+ - [ ] <f9> (ghostel-mode-map) — cj/ai-term — (ai-term.el:932)
+ - [ ] C-<f9> (ghostel-mode-map) — cj/ai-term-pick-project — (ai-term.el:933)
+ - [ ] M-<f9> (ghostel-mode-map) — cj/ai-term-close — (ai-term.el:934)
+ - [ ] C-S-<f9> (ghostel-mode-map) — cj/ai-term-close — (ai-term.el:935)
+ - [ ] <f12> (ghostel-mode-map) — cj/term-toggle — (term-config.el:415)
+ - [ ] C-SPC (ghostel-mode-map) — cj/term-send-C-SPC — (term-config.el:416)
+- [ ] Version control / magit
+ - [ ] M-g (git-commit-mode-map) — gptel-magit-generate-message — (ai-config.el:498)
+ - [ ] N (magit-mode-map) — forge-pull — (vc-config.el:125)
+- [ ] Help / docs modes
+ - [ ] b (devdocs-mode-map) — devdocs-go-back — (help-utils.el:47)
+ - [ ] f (devdocs-mode-map) — devdocs-go-forward — (help-utils.el:48)
+- [ ] Org-roam dailies
+ - [ ] Y (org-roam-dailies-map) — org-roam-dailies-capture-yesterday — (org-roam-config.el:92)
+ - [ ] T (org-roam-dailies-map) — org-roam-dailies-capture-tomorrow — (org-roam-config.el:93)
+- [ ] Other mode maps
+ - [ ] C-<return> (slack-message-compose-buffer-mode-map) — slack-message-send-from-buffer — (slack-config.el:297)
+ - [ ] q (dashboard-mode-map) — nil (unbound) — (dashboard-config.el:223)
+ - [ ] q (show-kill-ring-mode-map) — show-kill-ring-exit — (show-kill-ring.el:67)
+ - [ ] <f2> (markdown-mode-map) — markdown-preview — (markdown-config.el:24)
+ - [ ] <remap> <shell-command> — dwim-shell-command — (dwim-shell-config.el:204)
+- [ ] key-translation-map / input-decode-map
+ - [ ] input-decode-map ESC [ A — [up] — (keyboard-compat.el:109)
+ - [ ] input-decode-map ESC [ B — [down] — (keyboard-compat.el:110)
+ - [ ] input-decode-map ESC [ C — [right] — (keyboard-compat.el:111)
+ - [ ] input-decode-map ESC [ D — [left] — (keyboard-compat.el:112)
+ - [ ] input-decode-map ESC O A — [up] — (keyboard-compat.el:115)
+ - [ ] input-decode-map ESC O B — [down] — (keyboard-compat.el:116)
+ - [ ] input-decode-map ESC O C — [right] — (keyboard-compat.el:117)
+ - [ ] input-decode-map ESC O D — [left] — (keyboard-compat.el:118)
+- [ ] Jumper
+ - [ ] jumper-prefix-key (computed at runtime) — jumper-map — (jumper.el:270) [computed key — exact binding depends on the variable value]
+
+Note: the global =M-S-<letter>= family is intentionally excluded (Appendix B). The
+arrow-key =input-decode-map= entries are the terminal setup the spec keeps (not the
+translation block being retired). =C-l= appears only minibuffer-local in
+=vertico-map=, consistent with Appendix C.
+
+* Review and iteration history
+** 2026-06-12 Fri @ 11:21:56 -0500 — Craig Jennings — author
+- What: initial draft. Problem, three-context analysis, the 4a1ecf64 regression
+ as motivating evidence, the one-map/two-prefix design, alternatives, five
+ open decisions, phased plan, acceptance criteria, readiness dimensions, and the
+ full C-; tree + M-S- family appendices.
+- Why: a touched key family broke in GUI and is dead in console; the fix path is
+ cross-cutting (18 keys, a translation layer to retire, a console-safety
+ architecture) with real trade-offs, so it clears the spec bar.
+- Artifacts: docs/specs/keybinding-console-safety-spec-doing.org; supersedes the
+ pre-template draft docs/design/keybinding-console-safety.org.
+** 2026-06-12 Fri @ 18:30:30 -0500 — Craig Jennings — review response
+- What: processed Craig's four review comments. Recorded his first-choice
+ direction — one console-safe =Control=+key prefix used everywhere (single-prefix
+ primary; the two-prefix design is now the documented fallback) — in the Summary,
+ Design, and D1. Added Appendix C, the console-safe single-prefix candidate table
+ (standout =C-l=; runners-up =C-q=/=C-o=/=C-t=). Reframed D3 around that pick.
+ Named the flawed test (=tests/test-launcher-meta-shift-keys.el=) and quoted its
+ =key-binding=-only assertion in Phase 0. Recorded Craig's decision that the
+ launcher apps (=eww=/=elfeed=/=calibredb=/=wttrin=) get new keys under a launcher
+ sub-prefix, not =M-x= (D2/D5, Phases 0/2), with =C-; a t= (=cj/toggle-gptel=,
+ =ai-config.el:541=) flagged as freeable space. Reserved =C-; L= for Pearl in
+ Appendix A and D5.
+- Why: Craig's review shifted the architecture from two-prefix to a single unified
+ console-safe prefix and resolved the apps disposition; the spec had to carry the
+ candidate data he asked for and reflect the choices through the phases.
+- Open: the specific prefix (Appendix C), the window and launcher sub-prefix
+ letters (D5) remain Craig's picks. D1–D5 still State: proposed pending those.
+** 2026-06-12 Fri @ 18:43:25 -0500 — Craig Jennings — decisions-as-TODO convention
+- What: switched the Decisions section to org TODO tasks. Each decision is =** TODO
+ Dn=, flips to =DONE= when Craig agrees, stays TODO with a =*** Discussion= child
+ thread when not. Added a =[0/5]= statistics cookie and a gate: spec Status cannot
+ reach =ready= while any decision is TODO. Current status: all 5 TODO (none fully
+ agreed — D1 awaits the prefix lock, D2 unreviewed, D3 awaits the prefix pick, D4
+ awaits the A/B pick, D5's apps half agreed but both sub-prefix letters open).
+- Why: replaces the inline =State: proposed/accepted= field with an org-native,
+ agenda-visible task + discussion-thread workflow, and makes the
+ all-decisions-resolved gate explicit and machine-checkable.
+** 2026-06-13 Sat @ 00:18:09 -0500 — Craig Jennings — Path 2 restructure + audit appendix
+- What: processed three more review comments. Restructured the phases to Path 2:
+ Phase 0 is a pure revert that unblocks the held push; Phase 1 prunes forgotten
+ keybindings (Appendix D); Phase 2 is the consolidation (migrate the common set,
+ retire the translation block) — the primary deliverable; Phase 3 (bind the
+ console-safe prefix) is now OPTIONAL and deferred until Craig picks the key. The
+ Decisions gate split accordingly: D2/D4/D5 gate the primary work, D1/D3 gate only
+ Phase 3, so the work runs to the prefix-assignment point and stops there.
+ Corrected Appendix C's premise (Meta transmits in the console as an ESC prefix, so
+ =M-<punctuation>= is a viable console-safe class); added the =C-'= row (rejected —
+ console-dead and already bound to flyspell) and the =M-<punct>= row. Added Appendix
+ D: every personal keybinding set outside the =C-;= tree and the =M-S-= family, as a
+ checkbox pruning tree (~190 bindings, inventoried by a read-only sweep).
+- Why: Craig pivoted to landing the consolidation first and treating the
+ console-safe prefix as a later switch-on, and wanted a one-time audit of his
+ set-and-forgotten keybindings while the keymap work was open.
+- Open: D1–D5 still TODO; the prefix (D1/D3) is now non-blocking. Phase 0 revert
+ pending so the push can proceed.
diff --git a/docs/specs/mcp-el-gptel-integration-spec-doing.org b/docs/specs/mcp-el-gptel-integration-spec-doing.org
new file mode 100644
index 000000000..f22e91959
--- /dev/null
+++ b/docs/specs/mcp-el-gptel-integration-spec-doing.org
@@ -0,0 +1,1438 @@
+:PROPERTIES:
+:ID: b4c274c5-8572-4a7b-b657-d315712bd6af
+:STATUS: doing
+:END:
+#+TITLE: Design: Wire mcp.el into GPTel for MCP server access
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-16
+#+OPTIONS: toc:nil num:nil
+
+* Status
+
+Draft (revision 3). Pre-implementation; no code shipped yet. The
+mcp.el package is cloned at =~/code/mcp.el/= (fork of
+[[https://github.com/lizqwerscott/mcp.el][lizqwerscott/mcp.el]]) but not wired into the config.
+
+Revision 3 tightens seven contracts the revision-2 review flagged:
+
+1. *GPTel confirmation* -- =gptel-confirm-tool-calls= is =nil= at
+ =ai-config.el:386=, which short-circuits every per-tool
+ =:confirm= slot. The integration flips it to =auto= as a hard
+ precondition.
+2. *Async timeout mechanics* -- replaced =with-timeout= (which
+ only supervises dynamic extent) with an explicit timer/callback
+ race for async tool calls.
+3. *Startup completion semantics* -- the hub's completion callback
+ is opportunistic, not authoritative; the stall timer + polling
+ =mcp-server-connections= is the source of truth.
+4. *Server identity at registration* -- walk
+ =mcp-server-connections= directly instead of parsing
+ =:category mcp-SERVER= out of =mcp-hub-get-all-tool=.
+5. *Server enablement* -- =cj/mcp-enabled-servers= defcustom lets
+ users disable a server without writing code. Profiles still
+ deferred.
+6. *Keymap pinned* -- =C-; a C= (Connect) is the MCP subprefix.
+ =M= (=gptel-menu=) and =m= (=cj/gptel-change-model=) stay
+ where they are.
+7. *mcp.el private-API isolation* -- a compat layer wraps every
+ =mcp--*= call so version drift surfaces in one place.
+
+Plus several smaller changes: every MCP tool registers async,
+description normalization adds a server-name prefix and a write
+risk note, =cj/mcp-start-on-entry-points= defcustom scopes
+startup triggers (default: full chat only), TRAMP processes
+local-only, doctor gains live-auth-check, =cj/mcp-wait-until-ready=
+command added, audit buffer surfaces failed servers prominently.
+
+* Problem
+
+GPTel exposes ten local tools today (=read_buffer=, =read_text_file=,
+=write_text_file=, =update_text_file=, =list_directory_files=,
+=move_to_trash=, =git_status=, =git_log=, =git_diff=, =web_fetch= --
+see =gptel-tools/=). Claude Code, by contrast, has access to nine
+external MCP servers (linear, notion, figma, slack-deepsat,
+google-calendar, google-docs-personal, google-docs-work, drawio,
+google-keep), each exposing 10-70 additional tools.
+
+The asymmetry means agentic work done in GPTel can't touch the same
+external systems Claude Code can. Wiring [[https://github.com/lizqwerscott/mcp.el][mcp.el]] into the config
+closes the gap: GPTel gains access to every MCP server Claude Code
+uses, modulo three claude.ai-hosted servers whose OAuth is bound to
+the Claude.ai session (see Non-Goals).
+
+* Goals
+
+1. GPTel sees every tool from the enabled subset of nine reusable
+ MCP servers in =gptel-menu=, grouped by server via the tool's
+ =:category= field.
+2. Servers spawn *asynchronously*. Opening GPTel never blocks on
+ MCP startup; tools arrive incrementally and =gptel-tools= updates
+ as each server reports its inventory. =cj/toggle-gptel= must
+ return without waiting for any MCP subprocess.
+3. Write/destructive MCP tools are gated by a confirmation prompt
+ the user actually sees. Two preconditions:
+ =gptel-confirm-tool-calls= is set to =auto= (so the per-tool
+ =:confirm= slot is honored), and write/destructive tools are
+ registered with =:confirm t=. Read-only tools execute without
+ confirmation.
+4. Secrets stay in =~/.claude.json= (single source of truth, shared
+ with Claude Code). The Emacs config reads env vars from there
+ at server-spawn time, with an mtime-aware cache. Secrets are
+ never echoed to status, errors, hub buffers, or tests.
+5. A per-server status alist tracks each server's lifecycle (idle /
+ starting / ready / failed / stopped) and is inspectable via
+ =cj/mcp-status= and a =cj/mcp-list-tools= audit buffer.
+6. Server-management commands live under a =C-; a C= (Connect)
+ subprefix so existing GPTel keys (=C-; a M=, =m=) aren't
+ disturbed.
+7. A failed server (network down, OAuth token expired, npx package
+ 404) is surfaced clearly via the OAuth-recovery pattern matcher
+ and does not block GPTel itself. Successful servers' tools are
+ available immediately; failed servers' tools are absent (not
+ stale).
+8. The config can swap between MELPA mcp.el and the local
+ =~/code/mcp.el/= checkout with a one-line uncomment, gated by a
+ capability check that asserts required API functions exist.
+9. A first-run =cj/mcp-doctor= command diagnoses missing
+ prerequisites (=npx=, =uvx=, =~/.claude.json=, per-server
+ commands, known local endpoints) and optionally runs a
+ live-auth probe before they fail at runtime.
+
+* Non-Goals
+
+- The three claude.ai-hosted MCP servers (Gmail / Drive / Calendar
+ served from =*.googleapis.com/mcp/v1=). Their OAuth is issued
+ by the Claude.ai session and is not transferable to GPTel.
+- *MCP resources and prompts.* v1 registers tools only.
+ Resource browsing and prompt invocation are tracked as
+ follow-ups; the local checkout has the API surface ready.
+- *Per-conversation tool profiles.* v1 ships
+ =cj/mcp-enabled-servers= for whole-server enable/disable;
+ profiles (different tool subsets per chat) wait for v1.5 once
+ usage shows whether they're needed.
+- *Auth-source migration.* Deferred until the OAuth re-auth flow
+ for expiring tokens is understood. Tracked in Open Questions
+ §Q3.
+- *Automated OAuth re-auth when tokens expire.* Out of scope; the
+ user re-authenticates via Claude Code, and the next GPTel
+ invocation picks up the refreshed values from =~/.claude.json=.
+- *Modifying mcp.el itself in this repo.* Upstream patches and
+ tests live in =~/code/mcp.el/= and ship via PRs to lizqwerscott's
+ master.
+
+* Verified API Contracts
+
+These were checked against the actual source before each revision.
+Behavior summarized here so implementation can rely on it.
+
+** GPTel confirmation gating (=gptel.el:2244=)
+
+The confirmation check is:
+
+#+begin_src emacs-lisp
+(if (and gptel-confirm-tool-calls
+ (or (eq gptel-confirm-tool-calls t)
+ (gptel-tool-confirm tool-spec)))
+ ;; ask user
+ ...)
+#+end_src
+
+When =gptel-confirm-tool-calls= is =nil=, the =(and ...)=
+short-circuits and the tool's =:confirm= slot is ignored.
+
+The defcustom default is ='auto=, which "seeks confirmation only
+when the corresponding tool spec has a non-nil :confirm slot"
+(=gptel.el:1601-1603=). =ai-config.el:386= currently sets it to
+=nil=.
+
+*Implementation consequence:* =ai-mcp.el= must =setq
+gptel-confirm-tool-calls 'auto= as part of its setup (and
+=ai-config.el= drops the explicit =nil= setting). Without this,
+write-gated tools register =:confirm t= and gptel ignores it.
+
+** mcp-hub callback ownership (=~/code/mcp.el/mcp-hub.el:53-90=)
+
+=mcp-hub--start-server= unconditionally appends its own six
+callbacks (=:initial-callback=, =:tools-callback=,
+=:prompts-callback=, =:resources-callback=,
+=:resources-templates-callback=, =:error-callback=) to whatever
+the caller passes. Per-server custom callbacks in the alist
+result in duplicate keyword arguments to =mcp-connect-server= --
+behavior implementation-defined.
+
+*Implementation consequence:* the integration does not slip custom
+callbacks through =mcp-hub-servers=. It uses
+=mcp-hub-start-all-server='s top-level completion callback as an
+opportunistic signal, walks =mcp-server-connections= directly for
+authoritative state, and uses a stall timer as the deadline.
+
+** mcp-hub-start-all-server completion semantics
+
+The hub's completion callback (=mcp-hub-start-all-server='s
+=CALLBACK= argument) fires when its internal counter reaches the
+total server count. The counter increments:
+
+- On immediate Elisp errors from =mcp-hub--start-server=.
+- When the =:inited-callback= passed to =mcp-hub--start-server=
+ fires, which happens inside the hub's =:tools-callback=.
+
+Async error paths flow through =:error-callback= -- which the hub
+also installs but does *not* obviously chain into the inited
+callback. Servers without tools may not pass through the tools
+callback in the same way.
+
+*Implementation consequence:* the callback is treated as an
+opportunistic readiness signal, *not* as "all initialized or
+failed". The authoritative state comes from polling
+=mcp-server-connections= (each entry has =mcp--status= of
+=connected= / =error= / =starting=) and from the stall timer
+deadline.
+
+** gptel-make-tool registration semantics (=gptel.el:1729-1820=)
+
+=gptel-make-tool= registers the tool into =gptel--known-tools=
+keyed by category + name. It does *not* add the tool to
+=gptel-tools= (the per-buffer active list). The existing local
+tools (=gptel-tools/git_log.el:97=) explicitly do:
+
+#+begin_src emacs-lisp
+(gptel-make-tool ...)
+(add-to-list 'gptel-tools (gptel-get-tool '("category" "name")))
+#+end_src
+
+*Implementation consequence:* the registration pipeline does
+both calls per tool, tracks tool names per server in a hash, and
+deregisters cleanly on restart/stop without disturbing local
+tools.
+
+** MELPA vs local checkout
+
+The local checkout (=~/code/mcp.el/=, tip =f10768e=) has HTTP
+transport (=mcp-http-process-connection=) and recent UX
+improvements (resource reading, imenu support, detail mode).
+MELPA parity not yet verified.
+
+*Implementation consequence:* =cj/mcp--assert-capabilities=
+checks for required functions at load time and signals a clear
+=user-error= if missing. Use-package block defaults to MELPA;
+the local-checkout =:load-path= line stays commented until the
+capability check tells us MELPA is missing something.
+
+* GPTel Confirmation Contract
+
+The single most consequential precondition for the safety story:
+
+** Current state
+
+=modules/ai-config.el:386= sets =gptel-confirm-tool-calls= to
+=nil=. This was a deliberate "allow tool access by default"
+choice when only the ten local tools existed -- all of which are
+either read-only (git_log, git_status, list_directory_files, etc.)
+or already wrap their own confirm prompts (web_fetch uses
+=:confirm t= but is ignored under the current setting; this was
+acceptable because the only "real" tool there is on a user-typed
+URL).
+
+** Required state
+
+The MCP integration cannot ship without flipping this to =auto=.
+Specifically, =modules/ai-mcp.el= must:
+
+1. =setq gptel-confirm-tool-calls 'auto= during its load.
+2. Audit the existing local tools and add =:confirm t= to any
+ that should be gated. =web_fetch= is the obvious candidate;
+ =write_text_file=, =update_text_file=, =move_to_trash= may
+ also warrant it depending on Craig's preference.
+
+The existing =ai-config.el:386= line is removed. A comment
+points readers at =ai-mcp.el= for the new value.
+
+** Verification test
+
+A test in =tests/test-ai-mcp-confirm-contract.el= asserts:
+
+- After =ai-mcp= loads, =gptel-confirm-tool-calls= is ='auto=.
+- A write-classified MCP tool registered with =:confirm t= takes
+ the confirmation branch in =gptel-send='s tool-dispatch code
+ (verified by stubbing gptel's confirm-prompt and checking it
+ fires).
+- A read-classified MCP tool registered with =:confirm nil= does
+ not take the confirmation branch.
+- Local =git_log= (=:confirm nil=) still runs without prompting.
+
+* Current State
+
+** =modules/ai-config.el=
+
+- =use-package gptel= block at lines 363-414, defer-loaded on the
+ =gptel= / =gptel-send= / =gptel-menu= commands.
+- =gptel-confirm-tool-calls nil= at line 386. Removed by this
+ integration; see § GPTel Confirmation Contract.
+- =cj/gptel-load-local-tools= (lines 71-96) loads the ten local
+ tools from =gptel-tools/=.
+- =cj/toggle-gptel= (lines 418-441) is the primary entry point
+ (=C-; a t=). Other entry points: =cj/gptel-quick-ask=
+ (=C-; a q=), =gptel-magit-commit-generate= (=g= in magit),
+ =cj/gptel-rewrite-with-directive= (=C-; a r=), =gptel-send=
+ (=C-RET= in gptel buffer).
+- =cj/ai-keymap= (lines 510-528) currently uses keys A B M d . f
+ b l m p q r R c s t x. =C= (uppercase) is free and becomes the
+ MCP subprefix.
+
+** =gptel-tools/=
+
+Ten =.el= files. =git_log.el= is the closest analogue;
+=web_fetch.el= demonstrates the =:confirm t= pattern.
+
+** =~/.claude.json=
+
+Mode 0600, ~75 KB. Top-level =mcpServers= key holds the nine
+servers we want. Env-var names per server (values redacted):
+
+| Server | Env vars |
+|----------------------+-------------------------------------------------------|
+| google-calendar | =GOOGLE_OAUTH_CREDENTIALS= |
+| google-docs-personal | =GOOGLE_CLIENT_ID=, =GOOGLE_CLIENT_SECRET=, =GOOGLE_MCP_PROFILE= |
+| google-docs-work | Same three vars (different values) |
+| google-keep | =GOOGLE_EMAIL=, =GOOGLE_MASTER_TOKEN= |
+
+* Design
+
+** Module split
+
+Implementation lives in =modules/ai-mcp.el=. =modules/ai-config.el=
+gains only autoload declarations and the =C-; a C= subprefix
+wiring.
+
+** Code organization outline (=ai-mcp.el=)
+
+The file is organized in seven sections so it stays readable as
+features land:
+
+1. *Constants and defcustoms* -- =cj/mcp-server-specs=,
+ =cj/mcp-claude-config=, =cj/mcp-enabled-servers=,
+ =cj/mcp-start-on-entry-points=, =cj/mcp-startup-timeout=,
+ =cj/mcp-tool-timeout=, =cj/mcp-tool-confirm-overrides=,
+ audit-log defcustoms.
+2. *Public commands* -- =cj/mcp-ensure-started=, =cj/mcp-hub=,
+ =cj/mcp-status=, =cj/mcp-list-tools=,
+ =cj/mcp-restart-failed=, =cj/mcp-restart-server=,
+ =cj/mcp-stop-all=, =cj/mcp-doctor=,
+ =cj/mcp-wait-until-ready=.
+3. *Pure helpers* -- Claude config reader, =cj/mcp--build-server-alist=,
+ =cj/mcp--redact=, =cj/mcp--confirm-p=,
+ =cj/mcp--normalize-description=.
+4. *mcp.el compatibility layer* -- 3-5 wrappers around private
+ API (=mcp--status=, =mcp--tools=, etc.). Single source of
+ version-drift risk.
+5. *Registration pipeline* -- =cj/mcp--register-tool=,
+ =cj/mcp--register-server-tools=,
+ =cj/mcp--deregister-server-tools=,
+ =cj/mcp--registered-tools= hash.
+6. *Async state machine* -- =cj/mcp--state=,
+ =cj/mcp--server-status=, =cj/mcp--on-all-started=,
+ =cj/mcp--stall-timer=, =cj/mcp--poll-status=.
+7. *UI* -- audit-buffer mode, doctor buffer, recovery-pattern
+ matcher, response prefixes.
+
+This explicit outline doubles as the file's table of contents in
+its commentary block.
+
+** Server inventory: data first
+
+The nine servers are described as a defconst of plists, with no
+secrets baked in:
+
+#+begin_src emacs-lisp
+(defconst cj/mcp-server-specs
+ '((:name "linear"
+ :transport http
+ :url "https://mcp.linear.app/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "notion"
+ :transport http
+ :url "https://mcp.notion.com/mcp"
+ :auth in-protocol
+ :risk write-capable)
+ (:name "figma"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "figma-developer-mcp" "--stdio")
+ :secret-args ("--figma-api-key" :figma-api-key)
+ :auth args-token
+ :risk arg-leak)
+ (:name "slack-deepsat"
+ :transport sse
+ :url "http://127.0.0.1:13080/sse"
+ :auth local
+ :risk write-capable)
+ (:name "drawio"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@drawio/mcp")
+ :auth none
+ :risk none)
+ (:name "google-calendar"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@cocal/google-calendar-mcp")
+ :env (:GOOGLE_OAUTH_CREDENTIALS t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-personal"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-docs-work"
+ :transport stdio
+ :command "npx"
+ :args ("-y" "@a-bonus/google-docs-mcp")
+ :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t)
+ :auth oauth
+ :risk write-capable)
+ (:name "google-keep"
+ :transport stdio
+ :command "uvx"
+ :args ("--from" "keep-mcp" "python" "-m" "server.cli")
+ :env (:GOOGLE_EMAIL t :GOOGLE_MASTER_TOKEN t)
+ :auth token
+ :risk write-capable)))
+#+end_src
+
+The same data drives the doctor check list, status labels, and
+recovery messages -- a single source of truth keeps them from
+drifting.
+
+** Server enablement
+
+#+begin_src emacs-lisp
+(defcustom cj/mcp-enabled-servers
+ (mapcar (lambda (s) (plist-get s :name)) cj/mcp-server-specs)
+ "List of MCP server names to start.
+Defaults to every server in `cj/mcp-server-specs'. Set to a
+shorter list to disable specific servers without editing the
+spec. Changes take effect on next `cj/mcp-restart-failed' or
+Emacs restart."
+ :type '(repeat string)
+ :group 'cj)
+#+end_src
+
+=cj/mcp--build-server-alist= filters by this list before
+returning. A user who wants only =linear= and =drawio= sets:
+
+#+begin_src emacs-lisp
+(setq cj/mcp-enabled-servers '("linear" "drawio"))
+#+end_src
+
+This is the answer to "100+ tools is overwhelming" without
+needing per-conversation profiles.
+
+** Entry-point policy
+
+Not every GPTel entry point should trigger MCP startup. Quick
+ask, rewrite, and magit commit-message generation are
+lightweight; spinning up nine subprocesses for a 50-word commit
+message is surprising overhead.
+
+#+begin_src emacs-lisp
+(defcustom cj/mcp-start-on-entry-points
+ '(toggle-gptel)
+ "GPTel entry points that trigger MCP startup.
+Symbols correspond to commands: `toggle-gptel', `gptel-send',
+`gptel-quick-ask', `gptel-rewrite-with-directive',
+`gptel-magit-generate-message'. Default: only full chat
+(`toggle-gptel')."
+ :type '(repeat symbol)
+ :group 'cj)
+#+end_src
+
+Each entry-point command checks membership before calling
+=cj/mcp-ensure-started=:
+
+#+begin_src emacs-lisp
+(defun cj/toggle-gptel ()
+ ...
+ (when (memq 'toggle-gptel cj/mcp-start-on-entry-points)
+ (cj/mcp-ensure-started))
+ ...)
+#+end_src
+
+** Claude config reader (mtime-cached, structured returns)
+
+#+begin_src emacs-lisp
+(defcustom cj/mcp-claude-config
+ (expand-file-name "~/.claude.json")
+ "Path to the Claude Code config that holds MCP server env vars."
+ :type 'file
+ :group 'cj)
+
+(defvar cj/mcp--config-cache nil
+ "Cons of (MTIME . PARSED) for `cj/mcp-claude-config'.")
+
+(defun cj/mcp--read-claude-config ()
+ "Return a structured result describing the Claude config state.
+Result shape:
+ (:ok t :data PLIST)
+ (:ok nil :reason missing-file)
+ (:ok nil :reason unreadable)
+ (:ok nil :reason malformed-json :message STR)
+Cached by mtime; subsequent calls reparse only on change."
+ ...)
+#+end_src
+
+** mcp.el compatibility layer
+
+All private-API access lives in 3-5 helpers documented with the
+upstream commit they target. This is the only file that touches
+=mcp--*= names; everything else calls these wrappers.
+
+#+begin_src emacs-lisp
+;; ai-mcp-compat -- isolates private mcp.el API.
+;; Verified against upstream commit f10768e (2026-05-16).
+
+(defun cj/mcp--server-status (connection)
+ "Return CONNECTION's lifecycle status: connected, error, starting."
+ (mcp--status connection))
+
+(defun cj/mcp--server-tools (connection)
+ "Return CONNECTION's discovered tool list (plists)."
+ (mcp--tools connection))
+
+(defun cj/mcp--server-name (connection)
+ "Return CONNECTION's logical server name."
+ (jsonrpc-name connection))
+
+(defun cj/mcp--assert-capabilities ()
+ "Signal `user-error' if any required mcp.el function is missing."
+ (dolist (fn '(mcp-connect-server mcp-make-text-tool
+ mcp-hub mcp-hub-start-all-server
+ mcp-hub-get-all-tool mcp-server-connections))
+ (unless (fboundp fn)
+ (user-error "mcp.el too old; missing %s. Upgrade or switch \
+to local checkout in `ai-mcp.el' use-package block" fn))))
+#+end_src
+
+If mcp.el renames a slot or changes a return shape, only these
+helpers break. Tests cover each helper against stub objects.
+
+** Startup model: async + state machine + polling
+
+Three state structures capture lifecycle:
+
+#+begin_src emacs-lisp
+(defvar cj/mcp--state 'idle
+ "Overall MCP integration state: idle, starting, partial, ready, failed.")
+
+(defvar cj/mcp--server-status nil
+ "Alist mapping server name to status plist:
+ (:state STATE :tool-count N :tools (NAME ...) :last-error STR
+ :started-at TIME :ready-at TIME)")
+
+(defvar cj/mcp--stall-timer nil
+ "Timer guarding against servers that never call back.")
+#+end_src
+
+=cj/mcp-ensure-started= is the only entry point consumers call:
+
+#+begin_src emacs-lisp
+(defun cj/mcp-ensure-started ()
+ "Schedule MCP startup if it hasn't run yet this session.
+Returns immediately. Servers spawn asynchronously."
+ (when (eq cj/mcp--state 'idle)
+ (setq cj/mcp--state 'starting)
+ (cj/mcp--assert-capabilities)
+ (cj/mcp--build-status-from-specs)
+ (setq mcp-hub-servers (cj/mcp--build-server-alist))
+ (message "MCP: starting %d server(s) in background..."
+ (length mcp-hub-servers))
+ ;; The hub callback is opportunistic. We poll status on each
+ ;; tick and the stall timer is the authoritative deadline.
+ (mcp-hub-start-all-server #'cj/mcp--on-hub-callback nil nil)
+ (cj/mcp--start-stall-timer)))
+#+end_src
+
+State transitions and authority:
+
+- *Hub completion callback (opportunistic).* Triggers an
+ immediate poll + registration pass for whatever's ready. Does
+ not signal completion by itself.
+- *Stall timer (authoritative deadline).* After
+ =cj/mcp-startup-timeout= (default 30 s), marks every server
+ still in =starting= state as =failed= with reason =timeout=,
+ registers tools from servers that did become ready, transitions
+ =cj/mcp--state= to its final value (=ready=, =partial=, or
+ =failed=).
+- *Polling (authoritative state).* =cj/mcp--poll-status= walks
+ =mcp-server-connections= and maps each entry's =mcp--status= to
+ =cj/mcp--server-status=. Called from the hub callback and from
+ the stall timer. Servers that transitioned through
+ =:error-callback= (which the hub doesn't chain into the inited
+ callback) show up here.
+
+Properties:
+
+- =cj/mcp-ensure-started= returns in <100 ms regardless of
+ subprocess state.
+- =mcp-hub-start-all-server= itself is async (third =SYNCP= arg
+ is =nil=).
+- Servers transition independently; tools land in =gptel-tools=
+ as each server reports inventory.
+- A server that never connects, never errors, and never reports
+ tools is caught by the stall timer.
+
+** Tool registration pipeline
+
+Walks =mcp-server-connections= directly, per server, after each
+status poll. This gives clean per-server bookkeeping without
+parsing the =mcp-SERVER= category prefix:
+
+#+begin_src emacs-lisp
+(defun cj/mcp--register-server-tools (server-name)
+ "Register every tool from the connected server SERVER-NAME.
+Idempotent: re-registration replaces the function pointer
+without duplicating menu entries."
+ (let ((connection (gethash server-name mcp-server-connections)))
+ (when (and connection
+ (eq (cj/mcp--server-status connection) 'connected))
+ ;; First, deregister any existing tools for this server.
+ (cj/mcp--deregister-server-tools server-name)
+ (dolist (raw-tool (cj/mcp--server-tools connection))
+ (cj/mcp--register-tool server-name raw-tool))
+ (cj/mcp--update-server-status server-name :state 'ready))))
+
+(defun cj/mcp--register-tool (server-name raw-tool)
+ "Register one tool from SERVER-NAME.
+RAW-TOOL is the plist from `mcp--tools' (untransformed)."
+ (let* ((remote-name (plist-get raw-tool :name))
+ (gptel-name (format "mcp__%s__%s" server-name remote-name))
+ (description (cj/mcp--normalize-description
+ server-name raw-tool))
+ (confirm-p (cj/mcp--confirm-p gptel-name remote-name))
+ ;; mcp-make-text-tool builds the closure; we async by default.
+ (mcp-plist (mcp-make-text-tool server-name remote-name t))
+ ;; Rewrite name + description + confirm after mcp.el builds the closure.
+ (gptel-plist (cj/mcp--rewrite-plist
+ mcp-plist
+ :name gptel-name
+ :description description
+ :confirm confirm-p
+ :async t
+ :category (format "mcp-%s" server-name))))
+ (apply #'gptel-make-tool gptel-plist)
+ (add-to-list 'gptel-tools
+ (gptel-get-tool
+ (list (format "mcp-%s" server-name) gptel-name)))
+ (push gptel-name
+ (gethash server-name cj/mcp--registered-tools))))
+#+end_src
+
+Key properties:
+
+- *Async by default.* All MCP tools register with =:async t=.
+ This avoids any sync MCP tool call blocking Emacs during
+ =gptel-send='s tool dispatch. Per-call timeout uses the
+ timer-race pattern (next subsection).
+- *Closure preserves remote name.* =mcp-make-text-tool= built
+ the function before we rewrote =:name=, so the closure calls
+ =mcp-call-tool SERVER REMOTE-NAME=, not the prefixed
+ =mcp__SERVER__TOOL=.
+- *Idempotent.* Each registration deregisters first, so
+ callbacks firing multiple times or restarts don't accumulate
+ duplicate entries.
+- *Description normalization.* See next subsection.
+
+** Per-call timeout: explicit timer/callback race
+
+=with-timeout= only supervises the dynamic extent of the form it
+wraps. For async tools where the function returns immediately
+and the callback fires later, it does nothing. The correct
+pattern is an explicit timer:
+
+#+begin_src emacs-lisp
+(defun cj/mcp--wrap-async-with-timeout (server-name remote-name
+ mcp-callback)
+ "Return a gptel-compatible async wrapper for an MCP tool.
+GPTel calls the returned function with (CALLBACK . ARGS).
+The wrapper races MCP's response against `cj/mcp-tool-timeout';
+whichever fires first wins, and late callbacks are ignored."
+ (lambda (gptel-callback &rest args)
+ (let* ((done nil)
+ (timer (run-at-time cj/mcp-tool-timeout nil
+ (lambda ()
+ (unless done
+ (setq done t)
+ (funcall gptel-callback
+ (format "MCP tool %s/%s \
+timed out after %ds"
+ server-name
+ remote-name
+ cj/mcp-tool-timeout)))))))
+ (mcp-async-call-tool
+ (gethash server-name mcp-server-connections)
+ remote-name
+ (cj/mcp--args-to-plist args)
+ (lambda (result)
+ (cancel-timer timer)
+ (unless done
+ (setq done t)
+ (funcall gptel-callback
+ (mcp--parse-tool-call-result result))))
+ (lambda (code message)
+ (cancel-timer timer)
+ (unless done
+ (setq done t)
+ (funcall gptel-callback
+ (format "MCP error %s: %s" code
+ (cj/mcp--redact message)))))))))
+#+end_src
+
+Both branches set =done= before invoking gptel's callback so a
+late response (e.g., MCP responds after the timer fired but
+before its sentinel cancels) doesn't deliver twice. Timer is
+canceled on the success and error paths.
+
+The closure that =mcp-make-text-tool= built gets replaced with
+this wrapper during registration (the =:function= slot of the
+rewritten plist).
+
+** Confirmation / safety policy
+
+All enabled tools are registered (per the goal of full
+visibility), but write/destructive tools get =:confirm t=.
+Classification is name-based:
+
+#+begin_src emacs-lisp
+(defconst cj/mcp--write-name-patterns
+ '("\\`create\\b" "\\`update\\b" "\\`delete\\b" "\\`remove\\b"
+ "\\`send\\b" "\\`post\\b" "\\`add\\b" "\\`move\\b"
+ "\\`invite\\b" "\\`share\\b" "\\`upload\\b" "\\`set\\b"
+ "\\`patch\\b" "\\`import\\b" "\\`sync\\b" "\\`merge\\b"
+ "\\`close\\b" "\\`reopen\\b" "\\`archive\\b" "\\`unarchive\\b"
+ "\\`approve\\b" "\\`reject\\b" "\\`label\\b" "\\`assign\\b"
+ "\\`reply\\b" "\\`comment\\b" "\\`trash\\b" "\\`restore\\b"
+ "\\`pin\\b" "\\`unpin\\b" "\\`copy\\b" "\\`rename\\b"))
+
+(defconst cj/mcp--read-name-patterns
+ '("\\`get\\b" "\\`list\\b" "\\`read\\b" "\\`search\\b"
+ "\\`find\\b" "\\`fetch\\b" "\\`view\\b" "\\`query\\b"
+ "\\`describe\\b" "\\`show\\b" "\\`check\\b"))
+
+(defcustom cj/mcp-tool-confirm-overrides nil
+ "Per-tool confirmation overrides.
+Alist mapping fully qualified MCP tool name (e.g.,
+\"mcp__linear__create_issue\") to t or nil. Wins over the
+pattern-based classifier."
+ :type '(alist :key-type string :value-type boolean)
+ :group 'cj)
+
+(defun cj/mcp--confirm-p (gptel-name remote-name)
+ "Return non-nil if the tool should register with `:confirm t'."
+ (let ((override (assoc gptel-name cj/mcp-tool-confirm-overrides)))
+ (cond
+ (override (cdr override))
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--write-name-patterns) t)
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--read-name-patterns) nil)
+ (t t)))) ; unknown → confirm
+#+end_src
+
+This is the second half of the safety story; the first half
+(=gptel-confirm-tool-calls 'auto=) is enforced by ai-mcp.el at
+load time.
+
+** Description normalization
+
+mcp-side descriptions are written for an arbitrary client and
+vary in quality. The registration pipeline prefixes a stable
+"server / write-risk" header so the agent and the user have
+consistent context:
+
+#+begin_src emacs-lisp
+(defun cj/mcp--normalize-description (server-name raw-tool)
+ "Return a normalized description string for RAW-TOOL.
+Prefix: [SERVER] for reads, [SERVER WRITE] for writes,
+[SERVER ?] for unknown classification. Then the upstream
+description, unchanged."
+ (let* ((remote-name (plist-get raw-tool :name))
+ (upstream (or (plist-get raw-tool :description)
+ "(no description provided by server)"))
+ (cls (cond
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--write-name-patterns) "WRITE")
+ ((seq-some (lambda (p) (string-match-p p remote-name))
+ cj/mcp--read-name-patterns) "")
+ (t "?"))))
+ (format "[%s%s] %s" server-name
+ (if (string-empty-p cls) "" (concat " " cls))
+ upstream)))
+#+end_src
+
+Tools in =gptel-menu= show as:
+
+#+begin_example
+[linear WRITE] Create a new Linear issue in a team.
+[linear] List issues in a Linear team.
+[google-keep ?] Frobnicate a note (unknown classification).
+#+end_example
+
+** Tool deregistration
+
+Three triggers remove tools from =gptel-tools=:
+
+- =cj/mcp-stop-all= -- removes every MCP-registered tool, clears
+ =cj/mcp--registered-tools=, calls =mcp-stop-server= per server.
+ Local tools untouched.
+- =cj/mcp-restart-server= -- removes tools for the named server
+ before re-registering.
+- =cj/mcp-restart-failed= -- deregister + re-register each
+ =failed= server.
+
+#+begin_src emacs-lisp
+(defun cj/mcp--deregister-server-tools (server-name)
+ "Remove every GPTel tool this integration registered for SERVER-NAME.
+Local tools (those not in `cj/mcp--registered-tools') are
+preserved."
+ (let ((mcp-tools (gethash server-name cj/mcp--registered-tools)))
+ (dolist (tool-name mcp-tools)
+ (setq gptel-tools
+ (cl-remove-if (lambda (tool)
+ (string= (gptel-tool-name tool) tool-name))
+ gptel-tools)))
+ (remhash server-name cj/mcp--registered-tools)))
+#+end_src
+
+** Secrets redaction
+
+=cj/mcp--redact= masks every secret-bearing field before any
+string surfaces in messages, errors, status buffers, hub
+displays, or test fixtures. Used by status formatting, audit
+buffer, failure surfacing, and error wrappers. Tests assert
+sentinel =REDACTED_TEST_SECRET= never appears in any user-facing
+output.
+
+** Process cleanup
+
+=kill-emacs-hook= gets one entry: =cj/mcp-stop-all=. Stdio
+process sentinels record abnormal exits into the per-server
+status.
+
+*Local-only constraint.* MCP server processes are always spawned
+under the local Emacs's =default-directory='s root. TRAMP /
+remote buffers do not relocate the spawn; MCP processes are
+local-only. This is enforced implicitly (mcp-hub-start-all-server
+uses =make-process= which is local) and documented in the
+commentary.
+
+** Privacy: external tool output in saved conversations
+
+MCP tool results land in the GPTel buffer. GPTel's autosave (when
+enabled) persists those results to
+=~/.emacs.d/ai-conversations/=. Concrete implications:
+
+- A Slack channel excerpt the agent fetched is now on disk.
+- A Google Docs snippet the agent quoted is now on disk.
+- A Linear issue body the agent read is now on disk.
+
+This is normal external-tool behavior, but users may not realize
+it. Two mitigations:
+
+- =ai-mcp.el='s commentary explicitly documents the autosave
+ privacy implication.
+- The audit buffer (=cj/mcp-list-tools=) includes a header note:
+ "Tool results land in =gptel-tools= responses; saved
+ conversations persist them. Use =cj/gptel-autosave-toggle= per
+ buffer to opt out."
+
+A future enhancement (V1.5+) could mark MCP-sourced tool output
+with a visible delimiter in the GPTel buffer so the user sees
+"this came from an external server" during the chat. Not v1.
+
+** Per-server auth matrix
+
+| =:auth= value | Servers | How it works | Recovery if failed |
+|---------------+---------+--------------+--------------------|
+| =in-protocol= | linear, notion | mcp.el HTTP transport handles OAuth handshake | Open URL surfaced in =cj/mcp-status= |
+| =local= | slack-deepsat | Local SSE; no auth but proxy must run | Start the local proxy |
+| =none= | drawio | No auth | n/a |
+| =args-token= | figma | API key in process args | Update Claude config, restart |
+| =oauth= | google-calendar, google-docs-* | OAuth token in env; refresh out-of-band | Re-auth via Claude Code, restart |
+| =token= | google-keep | Long-lived token in env | Regenerate, update Claude config, restart |
+
+Recovery surfaces via pattern matching on errors:
+
+#+begin_src emacs-lisp
+(defconst cj/mcp--recovery-patterns
+ '(("\\(401\\|unauthorized\\|token expired\\)"
+ . "Token expired -- re-auth via Claude Code, then C-; a C r SERVER")
+ ("\\(connection refused\\|ECONNREFUSED\\)"
+ . "Local endpoint unreachable -- check the upstream service is running")
+ ("\\(ENOENT\\|command not found\\)"
+ . "Missing dependency -- run `cj/mcp-doctor' to diagnose")))
+#+end_src
+
+** Timeouts
+
+| Timeout | Variable | Default | Behavior |
+|---------+----------+---------+----------|
+| Startup / tool discovery | =cj/mcp-startup-timeout= | 30 s | Server marked =failed/timeout=; integration continues with ready servers |
+| Per-call tool execution | =cj/mcp-tool-timeout= | 60 s | Tool call returns timeout string to agent via the timer-race wrapper; server stays connected |
+
+Both via defcustoms. Per-tool override possible via
+=cj/mcp-tool-timeout-overrides= alist.
+
+* Status UX
+
+** Echo-area summary: =cj/mcp-status=
+
+Single-line summary keyed off =cj/mcp--state=:
+
+| State | Echo |
+|-------+------|
+| idle | =MCP: not started. (C-; a t triggers it.)= |
+| starting | =MCP: starting (3/9 ready)...= |
+| partial | =MCP: 7/9 ready (failed: google-calendar, slack-deepsat).= |
+| ready | =MCP: 9/9 ready, 87 tools registered.= |
+| failed | =MCP: all 9 servers failed. C-; a C d to diagnose.= |
+
+** Wait until ready: =cj/mcp-wait-until-ready=
+
+For the case where the agent asks for a Calendar tool immediately
+after =cj/toggle-gptel=:
+
+#+begin_src emacs-lisp
+(defun cj/mcp-wait-until-ready (&optional timeout)
+ "Block until MCP startup completes or TIMEOUT seconds pass.
+TIMEOUT defaults to `cj/mcp-startup-timeout'. Returns the final
+state symbol (`ready', `partial', `failed', `starting')."
+ (interactive)
+ ...)
+#+end_src
+
+Bound to =C-; a C w=. Reports progress every second via
+=message= so the user sees countdown.
+
+** Audit buffer: =cj/mcp-list-tools=
+
+Tabulated-list buffer. Failed servers appear at the top with a
+red face so they're visually obvious. Each row:
+
+#+begin_example
+Server State Tools Confirm Description
+-------------------- -------- ----- ------- ------------------------------
+google-calendar FAILED 0 - Token expired; see status
+slack-deepsat FAILED 0 - Local proxy unreachable
+-------------------- -------- ----- ------- ------------------------------
+linear/list_issues ready - no List issues in a Linear team
+linear/create_issue ready - YES Create a new Linear issue
+linear/... ready 44 total
+...
+#+end_example
+
+Sort: failed servers first, then by server name, then by tool
+name. Keys: =g= refresh, =RET= jump to tool's category in
+gptel-menu, =r= restart server under point, =c= toggle confirm
+override for tool under point.
+
+* Commands & Keymap
+
+=C-; a C= becomes the MCP (Connect) subprefix. Existing keys are
+preserved: =M= keeps =gptel-menu=, =m= keeps
+=cj/gptel-change-model=.
+
+| Key | Command | Purpose |
+|-----+---------+---------|
+| =C-; a C h= | =cj/mcp-hub= | Open server-management buffer |
+| =C-; a C s= | =cj/mcp-status= | Echo state summary |
+| =C-; a C l= | =cj/mcp-list-tools= | Open audit buffer |
+| =C-; a C r= | =cj/mcp-restart-failed= | Restart failed servers |
+| =C-; a C R= | =cj/mcp-restart-server= | Restart a named server |
+| =C-; a C S= | =cj/mcp-stop-all= | Stop everything |
+| =C-; a C d= | =cj/mcp-doctor= | Diagnose prerequisites |
+| =C-; a C w= | =cj/mcp-wait-until-ready= | Block until ready |
+
+which-key labels mirror the table.
+
+** cj/mcp-doctor
+
+Diagnostic command. Two modes:
+
+- *Static* (default) -- no side effects, no network: capability
+ check, =npx=/=uvx= on PATH, Claude config parseability,
+ per-server env-var presence, local endpoint reachability.
+- *Live* (=C-u C-; a C d=) -- opt-in: invokes a single safe read
+ per auth class to verify OAuth tokens haven't silently expired.
+ For example, =gh_search= against linear is one tool call; same
+ for notion, google-calendar, google-docs, google-keep. Static
+ checks first; live probe only fires if static passes.
+
+Output buffer keys:
+
+- =c= copy diagnostic summary to kill ring (for pasting into
+ bug reports / notes).
+- =r= rerun all failed checks.
+- =q= quit.
+
+Each check row formats as: =PASS / FAIL / WARN CHECK RECOVERY=.
+
+* Implementation Plan
+
+Eight phases (was seven in rev 2; added Phase 1.5 for the
+confirmation-contract setup). Each ends with green ERT tests +
+a manual smoke test before the next.
+
+** Phase 1 -- Module + pure helpers
+
+Create =modules/ai-mcp.el=. Implement: =cj/mcp-server-specs=,
+=cj/mcp-enabled-servers=, =cj/mcp-start-on-entry-points=,
+=cj/mcp--read-claude-config=, =cj/mcp--get-env=,
+=cj/mcp--build-server-alist= (pure transformer; filters by
+=cj/mcp-enabled-servers=), =cj/mcp--redact=,
+=cj/mcp--confirm-p=, =cj/mcp--normalize-description=.
+
+Tests cover all of the above against fixtures.
+
+** Phase 1.5 -- Confirmation contract
+
+Flip =gptel-confirm-tool-calls= to ='auto= in =ai-mcp.el='s
+setup. Remove the =(setq gptel-confirm-tool-calls nil)= line
+from =ai-config.el=. Audit existing local tools and add
+=:confirm t= to any that should be gated (=web_fetch=
+guaranteed; =write_text_file=, =update_text_file=,
+=move_to_trash= per Craig's decision).
+
+Verification test in =tests/test-ai-mcp-confirm-contract.el=
+asserts the setting, the local-tool gating behavior, and that
+=git_log= (=:confirm nil=) still runs without prompting.
+
+** Phase 2 -- Compat layer + tool registration with fake inventory
+
+Add =ai-mcp-compat= helpers. Build the registration pipeline
+against a stubbed =mcp-server-connections=. Verify:
+- Tool name rewriting (=remote-name= stays in closure;
+ =gptel-name= is =mcp__SERVER__TOOL=).
+- =gptel-make-tool= + explicit =add-to-list 'gptel-tools=.
+- =:confirm= application per policy + overrides.
+- Description normalization adds expected prefix.
+- =cj/mcp--registered-tools= bookkeeping.
+- Deregister removes from =gptel-tools= without disturbing local
+ tools (test pre-populates =gptel-tools= with a local tool and
+ asserts it survives).
+- Re-register after deregister doesn't duplicate.
+- All tools register with =:async t=.
+
+** Phase 3 -- Async state machine + timeout wrapper
+
+Implement =cj/mcp-ensure-started=, =cj/mcp--on-hub-callback=,
+=cj/mcp--state= transitions, stall timer, polling. Implement
+=cj/mcp--wrap-async-with-timeout=. Stub
+=mcp-hub-start-all-server= with synthetic delayed callbacks and
+synthetic async errors.
+
+Verify:
+- =cj/mcp-ensure-started= returns in <100 ms regardless of stubs.
+- Hub callback triggers status poll + registration.
+- Stall timer fires for stuck servers.
+- Async error path (=:error-callback= without inited callback)
+ reaches =cj/mcp--server-status= via polling.
+- =cj/mcp--wrap-async-with-timeout=: timer-first ignores late MCP
+ response; MCP-first cancels timer; both branches deliver
+ exactly once.
+
+** Phase 4 -- First real connection (no auth)
+
+Wire one =drawio= or =slack-deepsat= server. Verify the stubbed
+Phase 3 behavior matches real subprocesses.
+
+** Phase 5 -- Status UX + commands + doctor (static)
+
+Implement =cj/mcp-status=, =cj/mcp-list-tools= (with failed
+servers at top, red face), =cj/mcp-doctor= (static mode only),
+=cj/mcp-wait-until-ready=, restart commands, keymap entries,
+which-key labels.
+
+Investigate =gptel-menu= refresh behavior: if the transient is
+already open when a new tool registers, does it pick it up on
+next invocation? Document and add an acceptance test:
+"register new tool while gptel-menu is open; close and reopen;
+new tool appears."
+
+** Phase 6 -- HTTP servers
+
+Add =linear= and =notion=. Test in-protocol OAuth handshake.
+Add live-auth-check mode to doctor.
+
+** Phase 7 -- Env-dependent stdio servers
+
+Add =figma=, =google-calendar=, =google-docs-personal=,
+=google-docs-work=, =google-keep=.
+
+** Phase 8 -- Privacy + audit polish
+
+Add audit buffer's privacy header, autosave commentary, audit-log
+hygiene (=cj/mcp-tool-audit-log-enabled= defcustom). Update
+=ai-mcp.el= commentary with the code-organization outline as a
+TOC.
+
+* Test Plan
+
+** Confirmation contract (Phase 1.5)
+
+- =gptel-confirm-tool-calls= is ='auto= after =ai-mcp= loads.
+- Write-classified MCP tool with =:confirm t= triggers confirm
+ prompt (stub gptel's prompt and assert it fires).
+- Read-classified MCP tool with =:confirm nil= does not trigger
+ prompt.
+- Pre-existing =git_log= (=:confirm nil=) does not trigger
+ prompt.
+- =web_fetch= (newly gated with =:confirm t=) triggers prompt.
+
+** Pure helpers (Phase 1-2)
+
+- =cj/mcp--read-claude-config=: good fixture, missing, unreadable,
+ malformed JSON, missing =:mcpServers=, missing server, empty
+ env, non-string env values. Cache invalidation on mtime
+ change.
+- =cj/mcp--build-server-alist=: each transport, each auth class,
+ env merge, args splicing for figma, no mutation of
+ =cj/mcp-server-specs=, filter by =cj/mcp-enabled-servers=.
+- =cj/mcp--redact=: bearer tokens, OAuth credentials,
+ TOKEN/KEY/SECRET/CREDENTIALS-suffixed env vars, URL query
+ tokens, figma args slot. Sentinel never leaks.
+- =cj/mcp--confirm-p=: read patterns, write patterns, unknown →
+ t, override map wins.
+- =cj/mcp--normalize-description=: prefix shape per
+ classification.
+
+** Registration pipeline (Phase 2)
+
+- Single tool registers into all three structures.
+- Two tools with same =:name= from different servers don't
+ collide.
+- Re-registration replaces function pointer without duplicating.
+- Deregister removes from =gptel-tools= without touching a
+ pre-populated local tool.
+- All tools register with =:async t=.
+- Confirm overrides win over patterns.
+
+** Compat layer (Phase 2)
+
+- Each =cj/mcp--*-server-*= helper returns expected value against
+ stub object.
+- =cj/mcp--assert-capabilities= signals when a required function
+ is missing.
+
+** State machine + timeout (Phase 3)
+
+- =cj/mcp-ensure-started= from idle returns in <100 ms with
+ delayed-callback stub.
+- Second call from =starting= is no-op.
+- Hub callback triggers status poll.
+- Stall timer marks slow servers =failed/timeout=.
+- Async error path: server emits error callback only (no inited
+ callback); polling catches it and marks =failed/error= within
+ stall window.
+- =cj/mcp--wrap-async-with-timeout=:
+ - MCP responds first, before timer: gptel callback fires once
+ with result; timer canceled.
+ - Timer fires first: gptel callback fires once with timeout
+ message; late MCP response is ignored (done flag).
+ - Error callback: cancels timer, fires once with redacted
+ error.
+
+** Async / freeze (Phase 3)
+
+- Stub =mcp-hub-start-all-server= to delay callbacks 5 s.
+ =cj/toggle-gptel= returns within 250 ms; buffer is
+ interactive.
+- Stub a server that never responds. After
+ =cj/mcp-startup-timeout=, server is =failed=,
+ =cj/mcp--state= is =partial= or =failed=.
+
+** Entry-point policy (Phase 5)
+
+- With =cj/mcp-start-on-entry-points '(toggle-gptel)=, calling
+ =cj/toggle-gptel= triggers startup; calling
+ =cj/gptel-quick-ask= does not.
+- Adding =gptel-quick-ask= to the list makes quick-ask trigger.
+
+** Local-tool preservation (Phase 2)
+
+- =cj/mcp-stop-all= removes only MCP-registered tools from
+ =gptel-tools=; local tools like =git_log= remain.
+- =cj/mcp-restart-server= removes only that server's tools;
+ other MCP servers' tools and local tools both remain.
+
+** Process / cleanup (Phase 4+)
+
+- =kill-emacs-hook= calls =cj/mcp-stop-all=; subprocesses exit.
+- =cj/mcp-stop-all= clears =gptel-tools= MCP entries and
+ =cj/mcp--registered-tools=.
+- Restart doesn't leak process buffers or duplicate process
+ objects.
+- Process sentinel records abnormal exits into status.
+
+** Partial availability (Phase 4+)
+
+- 8 of 9 servers ready, 1 failed: ready tools available;
+ failed-server tools absent.
+- Restart-failed only retries the failed one.
+
+** Saved-conversation behavior (Phase 7+)
+
+- After a successful MCP tool call, GPTel autosave (when on)
+ persists the tool result. Test asserts the saved file
+ contains the result text and the audit buffer's privacy
+ header is updated.
+- =cj/gptel-autosave-toggle= off → result not saved.
+
+** Keymap (Phase 5)
+
+- =C-; a C h/s/l/r/R/S/d/w= all bound after =ai-mcp= loads.
+- Existing =C-; a M=, =C-; a m= still bound to
+ =gptel-menu=, =cj/gptel-change-model=.
+- which-key labels present for every new binding.
+- No duplicate labels.
+
+** =gptel-menu= refresh (Phase 5)
+
+- Register new tool while a previously-opened gptel-menu is
+ closed; reopen; new tool appears. Document whether mid-open
+ refresh works.
+
+** No-real-process rule
+
+All tests in =tests/test-ai-mcp*= use stubs for =process-file=,
+=make-process=, =mcp-hub-start-all-server=,
+=mcp-server-connections=, =mcp--tools=, =mcp--status=. No real
+=npx=, no network, no real =~/.claude.json=.
+
+** Manual test matrix
+
+| Scenario | Expected |
+|----------+----------|
+| No =~/.claude.json= | Doctor warns; env-free servers still start |
+| Malformed Claude config | Status shows =malformed-json=; integration =failed= cleanly |
+| Network offline | HTTP servers fail; stdio servers start; status =partial= |
+| =npx= not on PATH | Doctor flags it; stdio servers fail with clear message |
+| One stdio server exits immediately | Sentinel records failure; others continue |
+| slack-deepsat endpoint down | Server =failed=; recovery message points at local proxy |
+| Google token expired | Server starts; tool calls fail; live-auth check (=C-u doctor=) surfaces it |
+| All servers available | =MCP: 9/9 ready, ~N tools registered= |
+| =cj/mcp-restart-failed= after fix | Only retried servers transition |
+| =cj/mcp-stop-all= then call a tool | Tool absent from =gptel-tools= |
+| Disable a server via defcustom | Doctor and status reflect the absence |
+| TRAMP buffer open + =cj/toggle-gptel= | MCP starts locally; no remote spawn |
+
+* Acceptance Criteria
+
+1. *No freeze.* =cj/toggle-gptel= returns in <250 ms with mcp.el
+ wired and nine real servers starting in background.
+2. *Incremental registration.* As each server reports tools,
+ =gptel-tools= updates; in-flight =gptel-send= calls see
+ newly-added tools on the next request.
+3. *No MCP failure breaks ordinary GPTel chat.* With every MCP
+ server failing, =cj/toggle-gptel= still opens a usable chat
+ buffer; non-tool prompts work normally; local tools (git_log
+ etc.) still callable.
+4. *Confirm gate works.* After =ai-mcp= loads, a write-classified
+ MCP tool actually prompts before invocation. Verified by a
+ test that fails if =mcp-async-call-tool= is invoked before
+ gptel's confirm-prompt stub fires.
+5. *Local-tool preservation.* =cj/mcp-stop-all= and per-server
+ restart remove only MCP-owned tools.
+6. *Partial availability.* With one failed server, status is
+ =partial=, ready servers' tools work, failed server's tools
+ absent.
+7. *Idempotent restart.* Calling =cj/mcp-restart-failed= twice
+ with no intervening change produces identical state.
+8. *No secret leakage.* Grep every user-facing output for
+ sentinel fixture secrets; zero matches.
+9. *Doctor coverage (static).* Identifies each diagnosable
+ failure in the manual test matrix.
+10. *Server enablement.* Setting =cj/mcp-enabled-servers= to a
+ subset starts only those servers; doctor reports the disabled
+ ones as expected-absent.
+
+* Risks
+
+** R1 -- mcp.el API drift behind compat layer
+
+Even with the compat layer, an upstream rename could break us if
+the capability check misses it.
+
+*Mitigation:* compat helpers document the upstream commit and
+file location. Tests cover each helper against stub objects;
+when mcp.el bumps, run those tests first.
+
+** R2 -- Cold-start latency for nine subprocesses
+
+Nine =npx -y= invocations cold-start over several seconds. Time
+to =ready= state may be 10-30 seconds on a cold machine.
+
+*Mitigation:* async model means user doesn't wait. Tools arrive
+incrementally; status indicator shows progress.
+=cj/mcp-wait-until-ready= for the rare case where the agent needs
+a specific tool immediately.
+
+** R3 -- OAuth token expiry surfaces silently
+
+A Google server starts cleanly but every tool call fails with
+auth errors.
+
+*Mitigation:* the OAuth recovery pattern matcher inspects every
+tool-call error. Live-auth-check mode in doctor proactively
+calls one safe read per auth class.
+
+** R4 -- Tool count balloons gptel-menu
+
+Up to 100+ tools. Even with category grouping, the transient
+menu is large.
+
+*Mitigation:* =cj/mcp-enabled-servers= lets users disable
+servers they don't need. Audit buffer is the alternate browser.
+Per-conversation profiles deferred to v1.5.
+
+** R5 -- =~/.claude.json= schema change
+
+Parser breaks if Anthropic restructures the file.
+
+*Mitigation:* =cj/mcp--read-claude-config= returns structured
+errors that surface in status and doctor. Integration degrades
+to "env-free servers work, env-dependent servers fail" rather
+than crashing.
+
+** R6 -- Process argument leakage (figma)
+
+figma's API key is in process args -- visible via =ps=,
+=/proc/PID/cmdline=, =list-system-processes=.
+
+*Mitigation:* accepted risk (the figma package only supports
+args-token). =cj/mcp--redact= ensures the key never appears in
+Emacs-side output. Commentary flags this.
+
+** R7 -- Confirmation fatigue from unknown-classification tools
+
+Default for unknown is =:confirm t=. A server with many tools
+matching neither read nor write pattern produces many prompts.
+
+*Mitigation:* audit buffer surfaces unknown classifications with
+their confirm state. =cj/mcp-tool-confirm-overrides= alist lets
+the user pin specific tools to =nil= once vetted. A "review
+unknowns" doctor pass could enumerate them on demand (v1.5).
+
+** R8 -- Subprocess accumulation across sessions
+
+If =kill-emacs-hook= is bypassed (kill -9, crash), subprocesses
+persist.
+
+*Mitigation:* =cj/mcp-doctor= can detect orphaned mcp processes
+via =list-system-processes= (v1.5 enhancement).
+
+* Open Questions
+
+** Q1 -- Should =gptel-menu= refresh after mid-call tool registration?
+
+Investigation during Phase 5. If gptel-menu's transient caches
+=gptel-tools= at open time, mid-call additions won't appear
+until close+reopen. Document the behavior; if it's a real
+pain, file a gptel upstream issue.
+
+** Q2 -- Should write-confirmation overrides be per-host?
+
+A v1.5 question: when per-conversation profiles land, the
+override alist could also be scoped (e.g., "in /work/ folder,
+auto-confirm google-docs-work writes"). Out of v1 scope.
+
+** Q3 -- Auth-source migration path for OAuth tokens
+
+Three candidate paths (Elisp OAuth client / Claude Code refresh
+script / timer-based refresh) all have meaningful complexity.
+Until one is viable, =~/.claude.json= stays the source.
+
+** Q4 -- Live-auth check cadence
+
+Doctor's live-auth mode is opt-in. Should there be a periodic
+auto-check (every N hours via timer) that catches expiry between
+explicit doctor runs? Adds complexity; defer until usage shows
+need.
+
+* Considered Alternatives
+
+** =gptel-mcp.el= (declined; cited as prior art)
+
+[[https://github.com/lizqwerscott/gptel-mcp.el][lizqwerscott/gptel-mcp.el]] is a 96-line wrapper from the same
+author as mcp.el that bridges mcp.el's tool inventory into gptel.
+It exposes five functions:
+
+- =gptel-mcp-register-tool= -- walks =mcp-hub-get-all-tool= and
+ calls =gptel-make-tool= on each plist.
+- =gptel-mcp-activate-all-tool= -- pushes tools into
+ =gptel-tools=.
+- =gptel-mcp-deactivate-all-tool= -- removes them by category +
+ name lookup.
+- =gptel-mcp-start-all-server-and-register= -- chains
+ =mcp-hub-start-all-server= with the register callback.
+- =gptel-mcp-dispatch= -- a transient menu with three keys (=s=
+ start, =A= activate, =C= deactivate).
+
+*Why this matters for the spec.* The package independently
+validates the integration shape this spec converged on:
+=mcp-hub-start-all-server= → walk connections → =gptel-make-tool=
+→ =add-to-list gptel-tools= is the canonical path.
+
+*Why we are not adopting it.* The package solves the trivial
+"wire tools through" problem but skips every concern this spec
+exists to address:
+
+| Concern | Spec | gptel-mcp.el |
+|---------+------+--------------|
+| GPTel confirmation contract (=gptel-confirm-tool-calls 'auto=) | Required precondition | Not addressed |
+| Tool-name collisions | Rewrites to =mcp__SERVER__TOOL= | Silent overwrite |
+| Confirm-on-write policy | Per-tool =:confirm t= for writes | All tools register with default |
+| Async startup contract | =cj/mcp-ensure-started= returns in <100 ms | Synchronous-feel start |
+| Async per-call timeout | Explicit timer/callback race | None |
+| State machine | =cj/mcp--state= + per-server status | None |
+| Server identity strategy | Walks =mcp-server-connections= directly | Uses =mcp-hub-get-all-tool= + parses =mcp-SERVER= |
+| Secrets handling | Reads env from =~/.claude.json= with mtime cache | None |
+| Deregistration tracking | =cj/mcp--registered-tools= hash, preserves local tools | Removes by category, no local-tool guarantee |
+| Server enablement | =cj/mcp-enabled-servers= defcustom | None |
+| Entry-point scoping | =cj/mcp-start-on-entry-points= defcustom | Manual via dispatch menu |
+| Status UX | =cj/mcp-status=, =cj/mcp-list-tools= audit buffer | Just dispatch menu |
+| OAuth recovery | Pattern matcher with per-auth-class recovery | None |
+| Secrets redaction | =cj/mcp--redact= applied everywhere | None |
+| mcp.el compat layer | Isolated wrappers around private API | Direct =mcp--*= access scattered |
+| Tests | 10 acceptance criteria + manual matrix + no-real-process | None |
+| Doctor / live-auth check | Static + live-probe diagnostic | None |
+
+Adopting it would force shipping safely or wrapping with
+everything specified above (two layers, no code reduction).
+
+*What we are taking from it.* Confidence the API path is right.
+The transient-dispatch UX was considered for the keymap (rev 2),
+but the keymap is now pinned to discrete commands under =C-; a
+C= so existing GPTel keys aren't disturbed (rev 3).
+
+* References
+
+- [[file:../../modules/ai-config.el][modules/ai-config.el]] -- =gptel-confirm-tool-calls nil= at
+ line 386 (removed by this integration); loader at lines 71-96.
+- [[file:gptel-tools-shortlist.org][gptel-tools-shortlist.org]] -- local-tools shortlist; MCP servers
+ slot in as the "external" tier.
+- [[file:gptel-agentic-tool-ideas.org][gptel-agentic-tool-ideas.org]] -- broader agentic-tool design.
+- [[id:a124dd0f-1f40-4533-aeb8-595d93e20865][gptel-gh-tool-spec.org]] -- sibling design; same confirm-on-write
+ pattern.
+- [[https://github.com/lizqwerscott/mcp.el][lizqwerscott/mcp.el]] -- upstream.
+- [[https://github.com/lizqwerscott/gptel-mcp.el][lizqwerscott/gptel-mcp.el]] -- considered and declined; see §
+ Considered Alternatives.
+- =~/code/mcp.el/mcp-hub.el:53-90,131-160= -- verified callback
+ ownership and start-all helper.
+- =elpa/gptel-0.9.8.5/gptel.el:1595-1607,2244-2245= -- verified
+ =gptel-confirm-tool-calls= semantics and tool-confirm gate.
+- =elpa/gptel-0.9.8.5/gptel.el:1729-1820= -- verified
+ =gptel-make-tool= registration.
+- =~/.claude.json= -- Claude Code config.
diff --git a/docs/specs/messenger-unification-spec.org b/docs/specs/messenger-unification-spec.org
new file mode 100644
index 000000000..f8d3b4734
--- /dev/null
+++ b/docs/specs/messenger-unification-spec.org
@@ -0,0 +1,212 @@
+:PROPERTIES:
+:ID: 4bfc2011-8ffc-4765-8886-91df12141171
+:STATUS: not-started
+:END:
+#+TITLE: Messenger Unification — Shared Window Placement and Key Conventions
+#+AUTHOR: Craig Jennings & Claude
+#+DATE: 2026-06-11
+#+STATUS: Draft — decisions 1-9 settled (Craig, 2026-06-11/12); held open for further ideas before Ready
+
+* Problem
+
+Three messengers live in this config — Signel (Signal), telega (Telegram), and
+emacs-slack — and each invented its own window placement and its own send/cancel
+chords. Switching between them means re-learning the same two gestures three
+times. The goal: chat windows rise from the bottom of the frame under one rule,
+C-c C-c acts as the okay button, C-c C-k cancels, and a messenger joins the
+convention with one registration call instead of bespoke config. The same
+registration should carry a shared verb set (attach now; next-unread,
+jump-to-chat later) so future chords land everywhere at once.
+
+* Current State (surveyed 2026-06-11)
+
+** Signel (fork at ~/code/signel, =signel-chat-mode=)
+
+- Placement: bottom 30% via a private =display-buffer-alist= entry
+ (=modules/signal-config.el:184=, matches =\`\*Signel: =).
+- Keys (bound in the fork, =signel.el:493=): RET and C-c C-c send
+ (=signel--send-input=), C-c C-k clears input (=signel--cancel-input=),
+ C-c C-a attaches a file.
+- Verdict: already the proposed convention. Becomes the reference backend.
+
+** telega (=telega-chat-mode=)
+
+- Placement: none configured — falls to display-buffer's defaults.
+- Keys (upstream =telega-chat.el=): RET sends
+ (=telega-chatbuf-newline-or-input-send=, line 1796); C-c C-k already cancels
+ (=telega-chatbuf-cancel-dwim=, line 1790 — also on C-M-c and ESC ESC);
+ C-c C-c is taken by =telega-chatbuf-filter-cancel= (line 1832).
+- Verdict: half-conformant. Cancel matches; confirm needs the chord, which
+ shadows filter-cancel (decision 4).
+
+** emacs-slack
+
+- Placement: room buffers route through =cj/slack--display-buffer=
+ (=modules/slack-config.el:105=) — reuse / some-window / pop-up, deliberately
+ landing beside current work in a split.
+- Keys: compose/edit buffers derive from =slack-edit-message-mode=, which
+ already binds C-c C-c send (=slack-message-send-from-buffer=) and C-c C-k
+ cancel (=slack-message-cancel-edit=) upstream (=slack-message-editor.el:46=).
+ Config adds C-<return> send (=slack-config.el:297=). Room buffers are
+ read-only; composing happens in the separate compose buffer.
+- Verdict: keys already conform in compose. The open question is placement
+ (decision 5).
+
+** ERC
+
+Present (=modules/erc-config.el=) but out of scope for v1; joins later with one
+registration call (decision 7).
+
+* Design
+
+Two cooperating mechanisms in one new library, =modules/cj-messenger-lib.el=.
+Each messenger's =*-config.el= makes a single registration call; the library
+owns the display rule and the keymap.
+
+** The registry
+
+#+begin_src elisp
+(cj/messenger-register 'signel
+ :buffer-match "\\`\\*Signel: " ; regexp, or a list of major modes
+ :chat-modes '(signel-chat-mode) ; hooks that enable the minor mode
+ :confirm #'signel--send-input
+ :cancel #'signel--cancel-input
+ :attach #'signel-attach-file)
+#+end_src
+
+- =:buffer-match= feeds the window-placement predicate.
+- =:chat-modes= names the major-mode hooks where =cj/messenger-mode= turns on.
+- The verb keys (=:confirm=, =:cancel=, =:attach=, future verbs) populate
+ buffer-local dispatch variables when the minor mode enables. A nil verb means
+ "not supported here" — the dispatcher reports it instead of erroring.
+
+** Window placement
+
+One =display-buffer-alist= entry, installed by the library:
+
+- Condition: =cj/messenger-buffer-p= — true when the buffer matches any
+ registered =:buffer-match=.
+- Action: =(display-buffer-reuse-window display-buffer-at-bottom)= with
+ =window-height= from a shared defcustom =cj/messenger-window-height=
+ (default 0.3) and =reusable-frames nil= — the exact shape signel uses today.
+ Signel's private entry in =signal-config.el= is removed in favor of this one.
+- A registration may override the height for one backend if a real need
+ appears; the default is the convention.
+
+Deliberately a normal bottom window (=display-buffer-at-bottom=), not a side
+window: side windows are atomic, refuse splits, and fight other display
+commands. The signel entry has proven the at-bottom shape in daily use. The
+geometry capture/replay helpers in =cj-window-toggle-lib.el= can be layered on
+later if remembered sizing is wanted (out of scope for v1).
+
+** The minor mode and dispatch
+
+=cj/messenger-mode=, a buffer-local minor mode whose keymap outranks the major
+mode's:
+
+- C-c C-c → =cj/messenger-confirm=
+- C-c C-k → =cj/messenger-cancel=
+- C-c C-a → =cj/messenger-attach=
+
+Each command funcalls its buffer-local dispatch variable
+(=cj/messenger--confirm-fn= etc.), set from the registry when the mode enables
+via the registered =:chat-modes= hooks. Unset verb → =user-error= naming the
+messenger and the missing verb. RET is untouched — every backend keeps its
+native RET behavior; the convention adds chords, it never removes keys.
+
+This is the established Emacs-wide C-c C-c / C-c C-k convention (org-capture,
+message-mode, with-editor/git-commit), so the muscle memory transfers in both
+directions.
+
+** Backend wiring (per messenger, in its existing config module)
+
+- Smoke (the ground-up signel replacement at =~/code/smoke=, decided
+ 2026-06-12): implements the conventions natively from day one — bottom
+ drawer, dismiss-preserving C-c C-k per decision 3, unread tracking feeding
+ jump-to-unread — per its architecture spec. Signel remains the running
+ reference until smoke reaches parity; =signal-config.el='s private display
+ entry retires at the switchover. Registration stays one call; smoke is the
+ reference backend. (Tracked in the smoke project's todo.)
+- telega: =:confirm #'telega-chatbuf-input-send=, =:cancel= wraps
+ =telega-chatbuf-cancel-dwim= (decision 3 ladder), =:buffer-match
+ '(telega-chat-mode)=.
+- Slack: compose modes get the minor mode for uniformity (shadowing upstream's
+ identical bindings — a no-op in practice); room-buffer placement per
+ decision 5.
+
+* Decisions
+
+1. Placement engine is =display-buffer-at-bottom= in a normal window, shared
+ height defcustom 0.3. Proven by signel. (Proposed.)
+2. One registry call per messenger is the entire integration surface; the
+ library owns the display rule and keymap. (Proposed.)
+3. Cancel semantics (Craig, 2026-06-11; superseded 2026-06-12): C-c C-k
+ dismisses, never destroys — (a) backend pending state (telega
+ edit/reply/forward) → the backend's own dwim cancel; (b) otherwise →
+ =quit-window=. Typed drafts are not cancel's business: input survives the
+ burial and is waiting at the prompt on the next visit (signel's
+ pending-input machinery, generalized). Where a backend wants an explicit
+ clear-draft, it kills to the kill-ring so the text is recoverable.
+ /Superseded version (2026-06-11):/ a three-rung ladder whose first rung
+ cleared typed input before a second press closed the window — dropped
+ because the first press destroyed text while dismissing nothing, and it
+ broke the org-capture/git-commit muscle memory where C-c C-k means
+ "abandon and dismiss" in one press.
+4. Telega shadow accepted (Craig, 2026-06-11): the minor mode's C-c C-c hides
+ =telega-chatbuf-filter-cancel= in telega chats. Craig doesn't use chat
+ filters; the command stays reachable via M-x and the C-c / filter flow.
+5. Slack joins the bottom convention (Craig, 2026-06-11): room buffers move
+ from the beside-work split to the shared bottom rule; =cj/slack--display-buffer=
+ is retired in favor of the library's placement entry. Compose buffers
+ conform via the minor mode as planned.
+6. v1 verb set: confirm, cancel, attach. Revised 2026-06-12 (Craig):
+ jump-to-unread is promoted from candidate to committed verb — a global
+ chord that raises the most recent unread conversation in the bottom
+ window, completing the pull flow (toast → chord → chat). Backends supply
+ an unread source at registration (=:unread=). Still candidates:
+ next/prev-unread, jump-to-chat picker, mark-read-and-bury.
+ Addendum from the 2026-06 config audit: the notification path is the same
+ unification shape on the inbound side — four messengers, four mechanisms
+ (signel hardened with truncation/sound-gating/fallback; slack unhardened;
+ ERC double-notifying; telega notifying not at all). A shared
+ =cj/messenger-notify= (title prefix, truncation, sound flag,
+ script-with-fallback) belongs in this library, registered per backend like
+ the verbs. Details in the audit's messengers findings in =todo.org=.
+7. ERC deferred; one registration call when wanted. (Proposed.) Google Voice
+ (SMS + dialer) is a future backend candidate behind its own [#C]
+ investigation task in =todo.org= — if it goes, it joins through the same
+ registration surface.
+8. RET is never rebound or removed. (Proposed.)
+9. No auto-open, ever (Craig, 2026-06-12): no backend claims the bottom slot
+ unbidden — awareness is pull-based (hardened notifications +
+ jump-to-unread). =signel-auto-open-buffer= stays nil and equivalent knobs
+ in other backends are configured off. The drawer is summoned by the user,
+ not by traffic.
+
+* Phases
+
+- *Phase 1 — library + signel (reference backend).* =cj-messenger-lib.el=
+ (registry, predicate, display rule, minor mode, dispatchers), TDD: ERT over
+ the pure parts (registration shape, buffer matching, dispatch with stub
+ fns, nil-verb error). Wire signel; retire its private display entry.
+- *Phase 2 — telega.* Registration + the decision-3 cancel ladder; audit what
+ else the minor-mode map hides in =telega-chat-mode-map=.
+- *Phase 3 — slack.* Per decision 5; conform compose buffers either way.
+- *Phase 4 — shared verbs + ERC.* jump-to-unread first (committed per the
+ decision-6 revision), then remaining decision-6 candidates, each verb
+ landing in every backend at once. ERC joins when wanted.
+
+Each phase ends with a manual-test checklist filed under the
+"Manual testing and validation" parent in =todo.org= (placement, each chord,
+the not-supported message), per the verification discipline.
+
+* Risks
+
+- Minor-mode shadowing in telega beyond C-c C-c — Phase 2 audits the C-c
+ prefix in =telega-chat-mode-map= before shipping.
+- Slack's many buffer modes: room buffers derive from =slack-buffer-mode=,
+ compose from =slack-edit-message-mode= — =:buffer-match= must name the right
+ ancestors or the placement rule over- or under-matches.
+- Live-daemon rollout: the display-buffer-alist swap and mode hooks need a
+ module reload plus re-opening existing chat buffers (already-open buffers
+ won't have the minor mode until their mode hook reruns).
diff --git a/docs/specs/music-config-without-emms-spec.org b/docs/specs/music-config-without-emms-spec.org
new file mode 100644
index 000000000..32fd67367
--- /dev/null
+++ b/docs/specs/music-config-without-emms-spec.org
@@ -0,0 +1,547 @@
+:PROPERTIES:
+:ID: 423bc355-18d3-4e39-9e7a-f768b865d95b
+:STATUS: not-started
+:END:
+#+TITLE: Design: music-config Without EMMS
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-15
+
+* Status
+
+Specification only. No implementation has been started.
+
+Effort: Large. This is a multi-week module rewrite involving process
+management, player state, playlist state, a playlist major mode, and updates
+across the existing music test suite.
+
+* Problem
+
+=modules/music-config.el= is currently an EMMS configuration module, not an
+independent music module. The useful workflows are local to this config, but
+the core state lives in EMMS: the playlist buffer is the source of truth, player
+state is reported through EMMS hooks, and track metadata is read through EMMS
+track objects.
+
+That shape makes the module harder to test and evolve than it needs to be.
+Simple playlist operations have to load or stub EMMS, and UI refresh,
+consume-on-finish, random history, and auto-advance are all coupled to EMMS
+player hooks.
+
+The target design is a standalone music module that owns playlist state,
+controls =mpv= directly through a small backend protocol, and renders a
+playlist buffer as a view over package-owned data.
+
+This remains a personal config module in =modules/music-config.el=. It should
+be internally coherent enough that it could later become a package, but v1 does
+not target MELPA or a public package boundary.
+
+* Goals
+
+- Keep the current user workflows: fuzzy add, recursive directory add,
+ Dired/Dirvish add, M3U load/save/edit/reload, append-track-to-M3U, radio
+ station creation, playlist window toggle, random/repeat/single/consume
+ controls, track reordering, and mpv playback.
+- Make EMMS unnecessary for =music-config.el= load, tests, and normal use.
+- Separate domain logic from playback and UI so helpers stay easy to test.
+- Replace EMMS-bound keymap entries with =cj/music-*= commands.
+- Keep playlist files portable M3U files.
+
+* Non-Goals
+
+- Reimplementing the full EMMS feature set.
+- Building a music library database, tag editor, or metadata indexer.
+- Supporting multiple player daemons in v1.
+- Supporting album art, lyrics, queue persistence across Emacs restarts, or
+ remote control protocols beyond mpv.
+- Publishing a standalone =cj-music= package, adding MELPA metadata, or
+ converting all personal configuration variables to public =defcustom= forms.
+
+* Existing EMMS Coupling
+
+The current module depends on EMMS in these areas:
+
+- Playback commands: =emms-pause=, =emms-stop=, =emms-next=,
+ =emms-previous=, =emms-start=, =emms-random=,
+ =emms-seek-forward=, =emms-seek-backward=, =emms-volume-raise=,
+ =emms-volume-lower=, and shuffle/repeat/random toggles.
+- Add/load/save commands: =emms-add-file=, =emms-add-directory-tree=,
+ =emms-play-playlist=, =emms-playlist-save=,
+ =emms-source-playlist-ask-before-overwrite=, and
+ =emms-playlist-clear=.
+- Playlist buffer operations: =emms-playlist-buffer=,
+ =emms-playlist-mode=, =emms-playlist-track-at=,
+ =emms-playlist-current-selected-track=, =emms-playlist-select=,
+ =emms-playlist-mode-go=, =emms-playlist-mode-bury-buffer=,
+ =emms-playlist-mode-center-current=,
+ =emms-playlist-mode-shift-track-up=,
+ =emms-playlist-mode-shift-track-down=,
+ =emms-playlist-mode-kill-track=, and
+ =emms-playlist-selected-marker=.
+- Track representation: =emms-track-name=, =emms-track-type=,
+ =emms-track-get=, =emms-track-simple-description=, and
+ =emms-track-description-function=.
+- Lifecycle hooks and state: =emms-player-started-hook=,
+ =emms-player-stopped-hook=, =emms-player-paused-hook=,
+ =emms-player-finished-hook=, =emms-playlist-cleared-hook=,
+ =emms-player-playing-p=, =emms-player-paused-p=,
+ =emms-random-playlist=, =emms-repeat-playlist=, and
+ =emms-repeat-track=.
+- EMMS setup: =emms-all=, =emms-mode-line-mode=,
+ =emms-playing-time-disable-display=, =emms-source-file-default-directory=,
+ =emms-playlist-default-major-mode=, =emms-player-list=,
+ =emms-player-mpv-parameters=, =emms-player-mpv-regexp=, and EMMS playlist
+ faces.
+
+Anything outside those areas, especially file discovery, safe filename
+generation, M3U parsing, and radio station file creation, can remain mostly
+unchanged.
+
+* Proposed Architecture
+
+** Data Model
+
+Introduce a package-owned track model:
+
+#+begin_src emacs-lisp
+(cl-defstruct cj/music-track
+ type ; 'file or 'url
+ name ; absolute file path or stream URL
+ title
+ artist
+ duration)
+#+end_src
+
+The =title=, =artist=, and =duration= slots are populated opportunistically from
+mpv IPC metadata after a track starts. V1 must still behave correctly when
+metadata is absent by displaying a filename or decoded URL.
+
+Introduce playlist state that belongs to this package:
+
+#+begin_src emacs-lisp
+(cl-defstruct cj/music-playlist
+ tracks
+ selected-index
+ file
+ repeat-playlist
+ repeat-track
+ random
+ consume
+ random-history)
+#+end_src
+
+The playlist buffer should render this state. It should not be the source of
+truth. Buffer text becomes a view over =cj/music-current-playlist=.
+
+Track construction should use a small type helper instead of EMMS's mpv regex:
+
+#+begin_src emacs-lisp
+(defun cj/music--track-type-from-name (name)
+ (cond ((string-match-p "\\`\\(?:https?\\|mms\\)://" name) 'url)
+ ((cj/music--valid-file-p name) 'file)
+ (t nil)))
+#+end_src
+
+** Read-Side State API
+
+UI code should read player and playlist state through package-owned helpers,
+not through backend internals:
+
+- =cj/music-playing-p=
+- =cj/music-paused-p=
+- =cj/music-current-track=
+- =cj/music-playlist-state=
+- =cj/music-track-description=
+
+The playlist header, modeline indicators, and tests should use these helpers.
+
+** Backend Protocol
+
+Playback should go through a narrow backend plist:
+
+#+begin_src emacs-lisp
+(:name 'mpv
+ :available-p cj/music-mpv-available-p
+ :play cj/music-mpv-play
+ :pause cj/music-mpv-pause
+ :resume cj/music-mpv-resume
+ :stop cj/music-mpv-stop
+ :seek cj/music-mpv-seek
+ :volume cj/music-mpv-volume
+ :status cj/music-mpv-status
+ :metadata cj/music-mpv-metadata)
+#+end_src
+
+The module should provide commands such as =cj/music-play=,
+=cj/music-pause=, =cj/music-stop=, =cj/music-next=, =cj/music-previous=,
+=cj/music-seek-forward=, =cj/music-seek-backward=,
+=cj/music-volume-raise=, and =cj/music-volume-lower=. Those commands operate
+on package playlist state and then call the selected backend.
+
+** State-Change Hooks
+
+Replace EMMS player hooks with one package-owned abnormal hook:
+
+#+begin_src emacs-lisp
+(defvar cj/music-state-change-functions nil
+ "Abnormal hook run when music player state changes.
+Each function receives a plist:
+(:event EVENT :track TRACK :error ERROR).")
+#+end_src
+
+Events for v1:
+
+- =started=
+- =paused=
+- =resumed=
+- =stopped=
+- =finished=
+- =error=
+- =playlist-changed=
+- =mode-changed=
+
+The mpv backend is responsible for dispatching player events from process
+sentinels and IPC event messages. Package features should subscribe here:
+
+- Header refresh runs on every event.
+- Random history records on =started= when random mode is active.
+- Consume mode removes the finished track on =finished=.
+- Auto-advance runs on =finished= unless playback was deliberately stopped.
+- Playlist-file reset runs on =playlist-changed= when the playlist is cleared.
+
+** mpv Backend
+
+V1 should use mpv JSON IPC from the start. Pause, seek, and volume are core
+workflow parity, and implementing them later would leave a worse player than
+the current EMMS+mpv setup.
+
+Spawn mpv with:
+
+#+begin_src sh
+mpv --no-video --quiet --audio-display=no \
+ --input-ipc-server=<SOCKET-PATH> TRACK
+#+end_src
+
+The socket path lives under =temporary-file-directory= (=/tmp/= on
+Linux/macOS, =%TEMP%= on Windows) and includes the effective UID and Emacs
+process id, e.g. =cj-music-mpv-1000-12345.sock=. On startup, remove stale
+=cj-music-mpv-*= sockets for the current UID when no matching process owns
+them. On Emacs exit, stop playback and remove the active socket.
+
+Minimum mpv version: 0.17 (when JSON IPC stabilized). All current
+Linux/macOS/Windows distributions ship something newer.
+
+Backend responsibilities:
+
+- Start mpv for the selected track and connect to the IPC socket with
+ =make-network-process=.
+- Send JSON commands for pause/resume, seek, and volume.
+- Subscribe to mpv events such as =playback-restart=, =pause=, and =end-file=.
+- Query metadata on track start with =get_property metadata= and update the
+ selected track's metadata slots.
+- Distinguish deliberate stops from natural track completion so the sentinel
+ does not auto-advance after an explicit stop.
+- Report process and IPC errors through =cj/music-state-change-functions= with
+ =:event error=.
+
+** Platform Support
+
+Linux and macOS are the primary v1 targets. Both expose mpv's JSON IPC over
+a Unix domain socket, which Emacs reads with =make-network-process :family
+'local=. All features (play/stop/next/previous, pause/resume, seek, volume,
+metadata) work identically on those platforms.
+
+Windows is best-effort. mpv on Windows uses named pipes
+(=\\.\pipe\<name>=) for IPC instead of Unix sockets, and Emacs's
+=make-network-process= does not natively connect to Windows named pipes.
+Rather than block v1 on a Windows IPC layer, v1 ships a degraded mode on
+Windows:
+
+- Spawn mpv with =start-process= and feed commands over stdin or via
+ per-command =call-process= invocations.
+- Available commands: play, stop, next, previous.
+- Not available on Windows in v1: pause/resume, seek, volume.
+- =M-x cj/music-doctor= reports the degraded state on Windows so the user
+ is not surprised by missing functionality.
+
+Craig's call, 2026-05-15: best-effort on Windows is acceptable for v1.
+Anyone who needs full Windows parity can fund a follow-up that wires named
+pipes via =mpvc.exe= shellout or a =w32-*= named-pipe client. This call
+unblocks the implementer to focus on the Linux/macOS path without spending
+v1 budget on Windows IPC plumbing.
+
+** Selected-Track Representation
+
+Use =selected-index= as the durable state value. The playlist buffer should
+display the selected/current track with an overlay plus a selected-track face.
+
+Reordering should:
+
+1. Swap entries in the playlist state's =tracks= vector/list.
+2. Update =selected-index= so it continues to point at the same logical track.
+3. Re-render the playlist buffer.
+4. Reposition the selected overlay.
+
+Consume mode should remove the finished track from playlist state, then
+re-render. It should not edit raw buffer text as the source of truth.
+
+** Playlist Buffer
+
+Define a package-owned major mode, for example =cj/music-playlist-mode=.
+
+The buffer should preserve the current key surface:
+
+- =RET= or =p= to play the selected track.
+- =SPC= to pause/resume.
+- =s= to stop.
+- =>= / =n= and =<= / =P= for next/previous.
+- =f= / =b= for seek forward/backward.
+- =+= / === / =-= for volume.
+- =a= to add music.
+- =A= to append the track at point to an M3U.
+- =c= / =C= to clear.
+- =L=, =S=, =E=, and =g= for playlist load/save/edit/reload.
+- =r=, =t=, =z=, and =x= for repeat playlist, repeat track, random, and
+ consume.
+- =Z= to shuffle.
+- =i= for track info.
+- =o= to jump to the playing track.
+- =q= to bury the playlist buffer.
+- =S-<up>= / =S-<down>= and =C-<up>= / =C-<down>= for reordering.
+
+The current overlay/header design can stay, but it should read from the
+package state APIs instead of EMMS variables.
+
+The active-window background highlight should be preserved, because it is part
+of the current playlist window affordance.
+
+** Playlist State Rules
+
+- Shuffle changes playlist order and clears random history.
+- Reload replaces playlist tracks from disk and clears random history.
+- Save writes only track names/URLs to M3U; random history and mode state are
+ not persisted.
+- Repeat playlist, repeat track, random, and consume are package-owned runtime
+ flags.
+- Clearing a playlist clears the associated M3U file path.
+
+** M3U Handling
+
+Keep M3U as the persistence format. Existing helpers should be reused or moved
+behind pure APIs:
+
+- =cj/music--m3u-file-tracks= should parse paths and URLs.
+- Saving should write one track per line, using relative paths where possible.
+- Radio station creation should keep writing =#EXTM3U= and =#EXTINF= entries.
+
+** Test Architecture
+
+The rewrite should not update every command-flow test with one-off mocks.
+Introduce a shared fake backend first:
+
+#+begin_src emacs-lisp
+;; tests/testutil-music-backend.el
+(defvar cj/test-music-fake-backend
+ '(:name fake
+ :available-p cj/test-music--fake-available-p
+ :play cj/test-music--fake-play
+ :pause cj/test-music--fake-pause
+ :resume cj/test-music--fake-resume
+ :stop cj/test-music--fake-stop
+ :seek cj/test-music--fake-seek
+ :volume cj/test-music--fake-volume
+ :status cj/test-music--fake-status
+ :metadata cj/test-music--fake-metadata))
+#+end_src
+
+The fake backend should keep a simple event ledger, for example
+=(:playing-p BOOL :paused-p BOOL :track TRACK :events LIST)=. Command tests
+bind =cj/music-current-backend= to this fake and assert ordered backend events
+instead of stubbing individual EMMS functions.
+
+Before rewriting non-pure command implementations, add or preserve
+characterization tests for:
+
+- =cj/music-next=
+- =cj/music-previous=
+- =cj/music-toggle-consume=
+- =cj/music-playlist-toggle=
+- =cj/music-playlist-load=
+- =cj/music-playlist-clear=
+
+The existing pure-helper tests should mostly survive unchanged. The command
+tests, random-navigation tests, consume tests, playlist-buffer tests, and header
+tests should migrate to package state plus the fake backend.
+
+The real mpv IPC client should have integration tests tagged =:slow= and
+skipped when =mpv= is not on =PATH=. Default =make test= should not depend on
+mpv being installed.
+
+** Performance Budget
+
+V1 should keep the UI responsive for realistic playlist sizes:
+
+- =cj/music-playlist-load= on a 1000-track M3U should complete in under 500 ms,
+ excluding disk cold-cache effects.
+- =S-<up>= and =S-<down>= should return control within 50 ms for playlists up
+ to 5000 tracks.
+- Pause/resume command dispatch over mpv IPC should complete in under 100 ms,
+ excluding audio-device resume latency.
+- Header refresh after metadata arrival should stay below a frame budget
+ target of 16 ms.
+
+Full playlist re-rendering is acceptable for load, clear, shuffle, reload, and
+consume-after-finish. Reordering should avoid an obvious O(n) full erase and
+insert on every keypress if it misses the 50 ms budget. Start simple, measure,
+then add incremental line swaps or rendered-line caching only if needed.
+
+Metadata extraction must be lazy. Query mpv metadata when a track starts and
+refresh the header when it arrives. Do not eagerly scan all tracks on playlist
+load.
+
+** Parity Walk
+
+Before removing the EMMS implementation, run a manual parity walk against the
+new implementation:
+
+1. =F10= opens the playlist in a bottom side window; =F10= again closes it.
+2. =C-; m a= completes files and dirs under =cj/music-root=; choosing a file
+ adds that track.
+3. Choosing a directory adds music files from that tree.
+4. In Dirvish, marking files and pressing =+= adds them.
+5. In the playlist, =RET= plays, =SPC= pauses, and =SPC= resumes.
+6. =>= advances and =<= goes back.
+7. =z= toggles random; next chooses randomly; previous uses random history.
+8. =r= toggles repeat-playlist.
+9. =t= toggles repeat-track.
+10. =x= toggles consume; finished tracks disappear.
+11. =S= saves an M3U in =cj/music-m3u-root=.
+12. =L= loads a saved playlist in order.
+13. =g= reloads the current M3U after manual edits.
+14. =E= opens the M3U for editing.
+15. =R= creates a radio-station M3U that can be loaded and played.
+16. =S-<up>= and =S-<down>= reorder tracks while state and view stay in sync.
+17. =c= and =C= clear the playlist.
+18. =q= buries the playlist buffer.
+19. =i= shows current track info.
+20. =o= centers the playlist on the current track.
+21. =+= and =-= adjust volume and the change persists across track changes.
+22. =f= and =b= seek forward/backward.
+
+* Migration Plan
+
+1. Extract pure helpers and tests into EMMS-free units: file validation,
+ recursive collection, M3U parsing/writing, safe filenames, radio station
+ content, URL/file track typing, and playlist state operations.
+2. Introduce package-owned track and playlist state structs.
+3. Add =cj/music-playlist-mode= and make it render package playlist state with
+ selected-track overlay support.
+4. Add =tests/testutil-music-backend.el= and migrate command-flow tests to the
+ fake backend.
+5. Implement the mpv backend in focused steps:
+ - Process spawn, socket path management, IPC connection, and state-change
+ hook plumbing.
+ - Play, stop, next, and previous, including finished-track auto-advance.
+ - Pause/resume, seek, and volume through IPC.
+ - Metadata read on track start through IPC.
+6. Rewire public commands and Dired/Dirvish integration to use the new
+ state/backend APIs.
+7. Replace EMMS functions in =cj/music-map= and the playlist-mode keymap with
+ =cj/music-*= commands.
+8. Remove =cj/emms--setup= and the on-demand EMMS loading pattern.
+9. Delete the =use-package emms= block once parity is covered.
+
+No EMMS compatibility adapter is planned. This is a personal config, and the
+cleaner migration is to keep existing public =cj/music-*= command names while
+swapping their implementation behind the scenes.
+
+* Acceptance Criteria
+
+- Loading =music-config.el= does not require EMMS or reference EMMS symbols.
+- =init.el= still loads after the =use-package emms= block is removed.
+- A new smoke test confirms =music-config.el= can be required in batch with no
+ EMMS package installed.
+- Existing focused music tests pass without EMMS preload or EMMS stubs.
+- =tests/testutil-music-backend.el= exists and command-flow tests use it
+ instead of direct EMMS stubs.
+- New tests cover playlist state, backend command dispatch, IPC command
+ formatting, M3U persistence, Dired/Dirvish add routing, and the EMMS-free load
+ smoke path.
+- Slow mpv IPC integration tests are tagged =:slow= and skipped when =mpv= is
+ unavailable.
+- The F10 and =C-; m= workflows still open/show the playlist and expose the
+ same high-level commands.
+- All keys from the current playlist-mode keymap work in
+ =cj/music-playlist-mode=.
+- M3U load/save/reload/edit and radio station creation work without EMMS.
+- Local-file and stream URL playback work through mpv.
+- Pause/resume, seek, and volume work through mpv IPC.
+- Random, repeat playlist, repeat track, consume, shuffle, and track reordering
+ are represented in package-owned state and covered by focused tests.
+- The parity walk passes.
+- The performance budget is met or deviations are documented with measurements.
+
+* Risks
+
+| Risk | Mitigation |
+|-----------------------------------------------+-------------------------------------------------------------------------------------------------------------|
+| mpv IPC socket races or stale sockets | Use UID/PID-stamped socket paths, clean stale sockets on startup, and remove the active socket on exit. |
+| Auto-advance fires after explicit stop | Track deliberate stop state and test sentinel/event ordering. |
+| Metadata availability differs by stream/file | Treat metadata as optional; filename/URL descriptions remain the fallback. |
+| Playlist buffer gets out of sync with state | Make state the only source of truth and render buffer text from state after every playlist mutation. |
+| Dirvish =+= workflow regresses | Include Dired/Dirvish add routing in migration and tests. |
+| Test rewrite spreads bespoke fakes everywhere | Add one shared fake backend and migrate command tests through it. |
+| Playlist rendering is too slow on large M3Us | Start simple, measure against the budget, and add incremental rendering only if needed. |
+| Rewrite is too broad for one commit | Split implementation by the migration plan; keep pure helpers and state changes separate from backend work. |
+
+* Considered But Not Chosen
+
+** Publish as a standalone MELPA package in v1
+
+Rejected for v1. A public package would require namespace cleanup,
+=Package-Requires=, autoload cookies, defgroups/defcustoms, license headers,
+README/CHANGELOG work, package-lint/checkdoc cleanup, CI matrix support, and
+removal of personal config dependencies like =user-constants= and
+=cj/custom-keymap=. That work is real and useful only after the personal
+module migration proves the design. The v1 scope stays in
+=modules/music-config.el=.
+
+** Rewrite every test individually
+
+Rejected. Roughly half of the current music tests exercise EMMS-backed command
+flows. Replacing each EMMS mock one at a time would duplicate setup and make
+backend semantics inconsistent across tests. A shared fake backend gives the
+rewrite one seam to test command dispatch, state changes, and event ordering.
+
+** Eager metadata extraction for every playlist track
+
+Rejected. Scanning metadata for every track on load would require either many
+short mpv invocations or another tag-reading dependency, and it scales poorly
+for large M3Us. V1 reads metadata lazily from mpv IPC only when a track starts.
+
+** Full incremental playlist renderer from the start
+
+Deferred. Incremental rendering is more complex and may not be needed for the
+actual playlist sizes in this config. The spec sets a budget instead: full
+rendering is fine where it meets the budget, and reorder operations should only
+gain incremental swaps or cached rendered lines if measurement shows a problem.
+
+** Depend on an external mpv IPC package immediately
+
+Undecided, not chosen as a requirement. A local JSON IPC helper may be small
+enough and easier to test in this config. Depending on an existing package is
+still open if the local helper starts to recreate too much protocol machinery.
+
+** Add =webm= and =ape= to =cj/music-file-extensions=
+
+Rejected. =~/music= contains a small number of =webm= and =ape= files (~22
+each), and mpv can play both, but Craig's call (2026-05-15) is to leave the
+extension list as-is. Those files stay silently filtered out of fuzzy
+completion and recursive directory adds. Easy to revisit later by adding the
+two strings to the list; no architectural impact.
+
+* Open Decisions
+
+- Exact mpv IPC client implementation: small local JSON helper or dependency on
+ an existing mpv IPC package.
+- Whether metadata should be cached only in memory or written into extended M3U
+ comments later.
diff --git a/docs/specs/org-faces-spec-implemented.org b/docs/specs/org-faces-spec-implemented.org
new file mode 100644
index 000000000..c88559061
--- /dev/null
+++ b/docs/specs/org-faces-spec-implemented.org
@@ -0,0 +1,154 @@
+:PROPERTIES:
+:ID: 35578114-8c29-43af-97a2-fdfea01a802e
+:STATUS: implemented
+:END:
+#+TITLE: Org Header-Row Faces — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-15
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | implemented |
+|----------+----------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: org-faces module + theme-studio app]] |
+|----------+----------------------------------------------------------------|
+
+* Summary
+
+A small config module, =org-faces-config.el=, that defines named, theme-agnostic faces for org's TODO keywords and priority cookies, wires them through =org-todo-keyword-faces= and =org-priority-faces=, and a matching theme-studio app titled "org-faces" so each is an editable, previewable element. It makes the agenda header row (keyword plus priority) a per-element themeable layer that reads as clearly custom, not built-in org.
+
+* Problem / Context
+
+Per-keyword and per-priority coloring was stripped from =org-config.el= earlier, so today every keyword (TODO, DOING, …) renders in org's built-in =org-todo= / =org-done=, and every priority cookie ([#A] through [#D]) renders in the single =org-priority= face — one color for all four. There's no way to color them individually.
+
+The dupre theme does carry a parallel custom set (=dupre-org-todo=, =dupre-org-priority-a..d=, with =-dim= variants), but it's theme-specific and currently unwired, so it colors nothing. And theme-studio has no surface for these at all: its org-mode app exposes only the built-in =org-priority=, and the preview shows a single [#A].
+
+The immediate driver is theme-testing the agenda: the header row is the densest, most-scanned part of the agenda, and right now its keyword/priority colors can't be tuned in theme-studio or distinguished from built-in org faces. The user wants each keyword and priority to be its own themeable element, in its own clearly-custom namespace, surfaced in its own theme-studio section.
+
+* Goals and Non-Goals
+
+** Goals
+- Each keyword (TODO, PROJECT, DOING, WAITING, VERIFY, STALLED, DELEGATED, FAILED, DONE, CANCELLED) and each priority (A-D) gets its own named face.
+- The faces live in one module (=org-faces-config.el=), in their own prefix, wired through =org-todo-keyword-faces= and =org-priority-faces=.
+- theme-studio surfaces them as a dedicated "org-faces" app (own section, alongside elfeed and mu4e) with one editable row per face and a header-row preview.
+- They render correctly on any theme (sensible defaults) and are overridden by the generated theme.
+
+** Non-Goals
+- Not editing the built-in org faces — the org-mode app keeps those.
+- Not a general org face overhaul; only the header-row keyword + priority set.
+- Not retiring or deleting the legacy =dupre-org-*= faces from dupre-faces.el in v1 — auto-dim is only repointed away from them (decision below).
+
+** Scope tiers
+- v1: =org-faces-config.el= (base + =-dim= faces, wiring, init load); =org-faces-*-dim= wired into auto-dim (replacing the orphaned dupre-org-* entries); the theme-studio "org-faces" app (faces, preview, seeds).
+- Out of scope: built-in org faces; non-keyword/priority org elements; terminal/document-rendered color sources.
+- vNext (log to todo.org): retire or migrate the legacy =dupre-org-*= faces in dupre-faces.el; a grouped-subsection preview.
+
+* Design
+
+A new bespoke app, "org-faces", joins theme-studio's application list next to elfeed and mu4e. Its faces are the config's custom header-row set, not org's built-ins, and the =org-faces-= prefix says so on every row.
+
+** For the user
+
+Pick "org-faces" in theme-studio's application dropdown. The face table lists each keyword and each priority as a normal editable row — foreground, background, style toggles, box, lock — exactly like every other package face. The preview renders agenda-style lines: each keyword shown in its own face, then a =[#A] [#B] [#C] [#D]= row in the four priority faces, so the ramp is visible and any cookie is click-to-select. Setting a color there writes it into the generated theme; once that theme loads, the real agenda's keywords and priorities pick it up. Because the faces are named =org-faces-todo=, =org-faces-priority-a=, and so on, it's obvious this is the config's layer rather than built-in org.
+
+** For the implementer
+
+=org-faces-config.el= defines one =defface= per keyword and per priority (=org-faces-todo= … =org-faces-cancelled=, =org-faces-priority-a= … =org-faces-priority-d=), each with a default foreground so the row is colored out of the box. It then sets =org-todo-keyword-faces= to map each keyword string to its face and =org-priority-faces= to map =?A..?D= to the priority faces. The module is required after org so the faces exist before org applies the maps; the =defface=​s themselves load eagerly, which is what org needs.
+
+theme-studio side, all mechanical against the existing bespoke-app machinery:
+- =face_data.py=: =ORGFACES_FACES= (the face-name list) and =ORGFACES_SEED= (default colors mirroring =org-faces-config.el=).
+- =generate.py=: one row in the =_BESPOKE_APPS= spec, =("org-faces","org-faces","orgfaces",ORGFACES_FACES,"org-faces-",ORGFACES_SEED)=.
+- =app_inventory.py=: add =org-faces= to =BESPOKE_APPS=.
+- =app.js=: =renderOrgFacesPreview= building the keyword lines and the priority-cookie row with =os('org-faces', face, text)=, registered under =orgfaces= in =PACKAGE_PREVIEWS=.
+- =build-theme.el= needs no change — the package tier already emits these faces.
+
+The =org-faces-= prefix is also theme-studio's label-strip prefix, so rows read as "todo", "priority a", etc.
+
+* Alternatives Considered
+
+** Reuse the existing dupre-org-* names
+- Good, because no new faces are defined.
+- Bad, because the names are theme-specific (dupre) and the goal is a theme-agnostic, clearly-custom namespace; they'd still need all the theme-studio wiring.
+- Neutral, because the auto-dim mappings already reference them, which both helps (dim variants exist) and hurts (couples the new layer to one theme).
+
+** Inline specs in org-todo-keyword-faces (no named faces)
+- Good, because it's the least code and needs no defface.
+- Bad, because theme-studio can only theme *named* faces; inline specs can't be edited or previewed there, which defeats the whole point.
+- Neutral, because org supports both forms equally at runtime.
+
+** Put these in the existing org-mode app rather than a new app
+- Good, because one fewer app in the dropdown.
+- Bad, because it blurs the built-in-vs-custom line the user explicitly wants drawn.
+- Neutral, because the preview would grow rather than a new one being added.
+
+* Decisions [4/4]
+
+** DONE Face prefix
+- Context: the prefix is the public face namespace and theme-studio's label-strip; it must read as custom, not built-in org.
+- Decision: We will use =org-faces-= (e.g. =org-faces-todo=, =org-faces-priority-a=).
+- Consequences: easier — cohesive with the module and section name, and a clean label-strip prefix; harder — it still begins with "org", so a newcomer could misread it as built-in.
+
+** DONE defface defaults vs inherit-only
+- Context: should the header row be colored on any theme, or only once a theme sets these faces?
+- Decision: We will give each face a real default =:foreground= so it's colored out of the box, overridable by the theme.
+- Consequences: easier — works on stock Emacs and any theme, and the theme-studio seed matches the live default; harder — the defaults are theme-blind, so a theme that doesn't override them shows the generic colors rather than its own palette.
+
+** DONE Auto-dim dim variants
+- Context: dupre defines =dupre-org-*= and auto-dim remaps them to =-dim= variants in unfocused windows; rewiring to =org-faces-*= would drop that dim treatment unless it's carried over.
+- Decision: We will include =org-faces-*-dim= variants in v1 and wire them into auto-dim, replacing the now-orphaned =dupre-org-*= entries in =auto-dim-config.el=, so keywords and priorities stay legible when a window is dimmed. The legacy =dupre-org-*= faces in =dupre-faces.el= are left defined-but-unused; retiring them stays vNext.
+- Consequences: easier — the dim treatment carries onto the new layer and dupre's mapping stops referencing orphaned faces; harder — doubles the face count (base + dim), touches =auto-dim-config.el=, and under the dupre theme the new faces use their defaults until dupre themes =org-faces-*=.
+
+** DONE Keyword coverage
+- Context: the vocabulary has 10 keywords; dupre only ever defined faces for 8.
+- Decision: We will give all 10 keywords (including DELEGATED and CANCELLED) their own face.
+- Consequences: easier — full control, no surprise fallbacks; harder — two more faces to seed and maintain.
+
+* Implementation phases
+
+** Phase 1 — org-faces.el module
+Define the base and =-dim= =defface=​s (all 10 keywords + A-D, each with real default foregrounds) and the =org-todo-keyword-faces= / =org-priority-faces= wiring, require it after org. Done when the agenda colors each keyword and priority through the new base faces. Verify with a full Emacs launch (the wiring is :config-adjacent).
+
+** Phase 2 — auto-dim integration
+In =auto-dim-config.el=, replace the =dupre-org-*= → =dupre-org-*-dim= entries with =org-faces-*= → =org-faces-*-dim=, so dimmed windows render the new layer through its dim variants. Done when an unfocused window shows keywords/priorities in their dim colors.
+
+** Phase 3 — theme-studio org-faces app
+Add the =face_data.py= face list and seed (base + dim), the =generate.py= spec row, the =app_inventory.py= entry, and the =app.js= preview plus registration. Done when "org-faces" appears in the dropdown next to elfeed/mu4e, the rows edit, the preview renders, and =make theme-studio-test= is green.
+
+** Phase 4 — generated-theme round-trip
+Set a color for an =org-faces-*= face in theme-studio, =make deploy-wip=, and confirm the real agenda picks it up. Done when the round-trip lands the color in Emacs.
+
+* Acceptance criteria
+- [ ] Each keyword (TODO … CANCELLED) renders in a distinct =org-faces-*= face in the agenda.
+- [ ] [#A]-[#D] render in distinct =org-faces-priority-*= faces.
+- [ ] theme-studio shows an "org-faces" app beside elfeed/mu4e, one row per face, with a header-row preview.
+- [ ] A color set in theme-studio for an =org-faces-*= face appears on the real agenda after =deploy-wip=.
+- [ ] =org-faces-config.el= byte-compiles clean and the theme-studio suite is green.
+
+* Readiness dimensions
+- Data model & ownership: faces are user-authored defaults (=org-faces-config.el=) overridden by the generated theme; theme-studio edits the package-tier specs; =org-todo-keyword-faces= / =org-priority-faces= own the keyword/priority wiring.
+- Errors, empty states & failure: a keyword with no mapping falls back to org's built-in =org-todo= / =org-done= — acceptable, not silent data loss. N/A otherwise (no I/O).
+- Security & privacy: N/A — faces only.
+- Observability: the live agenda and the theme-studio preview are the visible surface; a wrong color is self-evident.
+- Performance & scale: N/A — about a dozen faces.
+- Reuse & lost opportunities: rides org's built-in keyword/priority face hooks and build-theme's existing package tier; no converter changes.
+- Architecture fit & weak points: same bespoke-app pattern as elfeed/mu4e. Weak point is load order — faces must exist before org applies the map; eager =defface= at module load covers it.
+- Config surface: =org-todo-keyword-faces=, =org-priority-faces= (set by the module), plus the faces themselves; all overridable.
+- Documentation plan: a header comment in =org-faces-config.el= and this spec; no user-facing docs needed.
+- Dev tooling: existing =make theme-studio-test= and =make deploy-wip= cover build and round-trip.
+- Rollout, compatibility & rollback: additive; rollback is removing the module and the theme-studio app. It re-introduces keyword/priority coloring that was deliberately stripped earlier, now as a named themeable layer.
+- External APIs & deps: =org-todo-keyword-faces= and =org-priority-faces= are standard org defcustoms (verified); build-theme's package tier already emits inherit/box/style (verified this session).
+
+* Risks, Rabbit Holes, and Drawbacks
+- Poor default colors fight unset themes. Dodge: seed conservatively, lean on theme override.
+- Load order: the faces must be defined before org renders the agenda. Dodge: eager defface, require before/with org.
+- Scope creep into the dupre/auto-dim migration. Dodge: it's explicitly vNext with its own decision.
+
+* Review and iteration history
+** 2026-06-15 Mon @ 01:51:57 -0500 — Craig — author
+- What: initial draft.
+- Why: the A-D priority request generalized into a clearly-custom, theme-studio-surfaced header-row face layer; the prefix, default-color policy, and dupre/auto-dim reconciliation are real trade-offs worth settling on paper first.
+- Artifacts: [[file:../../todo.org][todo.org org-faces task]]; theme-studio bespoke-app machinery (face_data.py, generate.py, app.js).
diff --git a/docs/specs/signal-client-spec-doing.org b/docs/specs/signal-client-spec-doing.org
new file mode 100644
index 000000000..beee0acf1
--- /dev/null
+++ b/docs/specs/signal-client-spec-doing.org
@@ -0,0 +1,254 @@
+:PROPERTIES:
+:ID: 0cabd6ee-c458-47b5-a8af-3ee054b25821
+:STATUS: doing
+:END:
+#+TITLE: Design: Signal client in Emacs (forked signel)
+#+DATE: 2026-05-26
+#+STATUS: Draft
+
+* Problem
+I want a Signal chat client inside Emacs: link it as a secondary device to my phone, pick a contact from my contact list, hold a text 1:1 conversation (read and send), and get a desktop notification on incoming messages, with an optional sound. Signal has no official API, so this is built on =signal-cli=, the mature headless CLI, driven over JSON-RPC.
+
+* Non-Goals
+- Groups, attachments, stickers, reactions, read receipts, typing indicators in the first version (text 1:1 only). The fork base already supports several of these, so they are deferred, not forbidden.
+- Replacing the phone as primary. This is a *linked secondary device*, like Signal Desktop.
+- Registering a phone number standalone.
+- Notifying for the conversation I'm actively viewing.
+
+* Assumptions
+- *Researched fact:* signal-cli (AsamK) is mature, headless, and exposes JSON-RPC; it runs as =signal-cli -a ACCOUNT jsonRpc=. Source: https://github.com/AsamK/signal-cli
+- *Researched fact:* signel (keenban) is GPL-3, single-file (642 lines), on MELPA, and already implements the signal-cli JSON-RPC process loop, a read-only chat buffer with guarded prompt, send, sync handling, media rendering, and an active-chats dashboard. Source: https://github.com/keenban/signel
+- *Researched fact:* signel is stale — all 40 commits in a ~10-day burst in Jan 2026, nothing since 2026-02-04, an unattended issue tracker (5 open, filed Mar 2026, none answered), no PRs ever, ~26 stars. Highest-severity open bug is #2 (incoming messages clobber in-progress input); no crashes, no signal-cli incompatibility, no data loss. So: fork-and-own, not depend-and-track.
+- *Researched fact:* signal-cli is AUR-only on Arch (=yay -S signal-cli=), needs a JRE (OpenJDK 17+, satisfied by the installed OpenJDK 26).
+- *Assumption (to confirm before building):* signal-cli must be updated roughly every three months or Signal-Server rejects the client version. (Widely reported; confirm cadence against the project's NEWS when it bites.)
+- *Researched fact:* signal-cli =listContacts= returns a contact list in a shape usable for a completing-read picker. Verified 2026-05-26 against the live linked account (94 real contacts; =cj/signal--parse-contacts= ERT-covered).
+
+* Approaches Considered
+
+** Recommended: fork signel into ~/code/signel and own it
+Clone is already at =~/code/signel=. Wire via =use-package :load-path= like the org-drill and auto-dim-other-buffers forks. The clean 642-line core handles the hard plumbing; layer three focused changes plus integration on top.
+- Pros: full control over the exact spec (contact picker, notify-when-not-viewing, sound toggle) in cj/ idioms; the hard JSON-RPC/receive/buffer/media work is already done; upstream is dead-quiet so there is no divergence cost to forking.
+- Cons: own the maintenance (the signal-cli update treadmill, reconnect/resync) and signel's existing bugs.
+
+** Rejected: install signel from MELPA + advice the internals
+=(use-package signel :ensure t)=, add the contact picker and link command as additive config, advice =signel--handle-receive= for the notify behavior.
+- Why not: the notification change and the #2 input-clobber fix are internal edits; advising them is fragile and ugly. With upstream dead, forking loses nothing and keeps those edits clean.
+
+** Rejected: custom Emacs client from scratch on signal-cli
+- Why not: rewrites the JSON-RPC loop, buffer management, and media that signel already does cleanly. "Read signel as reference then retype it" is forking with extra steps.
+
+** Rejected: signal-cli-rest-api (Docker)
+- Why not: a Docker dependency for a personal Emacs feature is heavy; two moving parts instead of one daemon.
+
+** Rejected (tail): Signal-as-MCP-tool via gptel
+- Why not: agent-mediated messaging, not a chat client; undershoots "pick a contact and chat"; foxl-ai MCP server is v0.1.1 and unproven.
+
+** Rejected (tail): bridge to ERC via a Signal↔IRC gateway
+- Why not: a second daemon plus a bridge to keep alive; double the breakage surface; bridge maturity unverified.
+
+** Rejected (tail): org-backed (receive-hook writes per-contact org)
+- Why not: org is not a live chat surface; reframes the picked option into note-taking.
+
+* Design
+
+** Fork integration
+- Fork lives at =~/code/signel= (already cloned). New module =modules/signal-config.el= wires it with =use-package signel :load-path "~/code/signel" :ensure nil=, mirroring the org-drill and auto-dim forks.
+- Keybindings under a dedicated prefix (candidate =C-; M= for Messages, since =C-; S= is Slack). Commands: start/link, contact picker, dashboard, toggle sound.
+- =signel-account= set from a defcustom or authinfo, not hardcoded.
+
+** Three changes on top of the fork
+1. *Contact picker.* New command =cj/signel-pick-contact= (or rename signel's =signel-chat=): call signal-cli =listContacts= over JSON-RPC, cache name→number, present a =completing-read= of names, open the chat buffer for the chosen contact. signel today opens by raw phone number and only lists chats that already received a message.
+2. *Linking / auth.* New command =cj/signel-link= wrapping =signal-cli link -n "Emacs"=, capturing the =tsdevice:= URI and rendering it as a scannable QR (via =qrencode= to an image buffer, or a CLI QR) so the phone's Linked Devices can scan it. signel assumes an already-linked account.
+3. *Notification behavior.* Edit =signel--handle-receive='s notify block: (a) suppress the notification when the message's chat buffer is the selected window's buffer (actively viewing); (b) route through Craig's =notify= script instead of bare =notifications-notify=; (c) sound off by default behind a defcustom toggle (=cj/signel-notify-sound=, default nil).
+
+** Folded-in upstream fix
+- Issue #2 (incoming messages clobber in-progress input): the redraw in =signel--insert-msg= / =signel--draw-prompt= replaces the prompt region while the user may be mid-type. Preserve and restore any unsent input across the insert. Fix it in the fork since it sits right next to the notification edit.
+
+** Data flow
+signal-cli (linked secondary device) ⇄ JSON-RPC over the subprocess stdio ⇄ signel process filter → dispatch → receive handler → chat buffer insert + notify. Send: chat-buffer prompt → =send= RPC. No persistence beyond what signal-cli stores; Emacs holds session state (contact cache, active chats) in memory.
+
+** Error handling
+- signal-cli not installed / not linked → =user-error= with the remedy (install, or =cj/signel-link=). signel already guards the missing executable and unset account.
+- RPC errors map to the originating chat buffer (signel already does this).
+- Process death → sentinel logs; add a visible message and a restart hint.
+
+** Testing
+- Pure helpers (contact-list parsing from a fixture JSON, the notify-suppression predicate given a buffer/window state, the input-preserve logic) get ERT unit tests with mocked signal-cli output — no live account needed.
+- The live loop (link, receive, send, notify) is verified manually against a linked account (scripted manual checklist), since it needs the phone and a real signal-cli.
+
+** Observability
+- signel already logs RPC traffic to =*signel-log*=. Keep it; it's the diagnostic surface for the update-treadmill breakages.
+
+* Open Questions
+- [ ] Fork-vs-MELPA+advice is decided (fork). Record as an ADR via =arch-decide= if a formal record is wanted.
+- [X] Keybinding prefix: =C-; M= (Messages). Decided 2026-05-27 (workflow spec D1). Leaf keys: =m= message, =s= self, =d= dashboard, =l= link, =q= stop, =SPC= connect.
+- [X] Account source: defcustom in =signal-config.local.el= (=signel-account=, loaded by =cj/signal-private-config-file=). Decided 2026-05-27. The phone number is an identifier rather than a credential, so a gitignored local-config file is the right home (no GPG prompt at connect time, off the public mirror).
+- [X] Fork remote: keep as a local checkout at =~/code/signel= for now. Decided 2026-05-27. Upstream is dead-quiet so there's no remote to track; revisit if/when divergence is large enough that a backup remote on cjennings.net adds value.
+
+* Next Steps
+1. Install signal-cli: =yay -S signal-cli= (interactive, Craig).
+2. Link as a secondary device (=cj/signel-link= once built, or =signal-cli link= directly) — scan the QR from the phone.
+3. Implement on the fork against the live engine (TDD the pure helpers, manual-verify the live loop) via =/start-work=.
+4. archsetup request to add signal-cli to the standard install — sent 2026-05-26.
+
+* Initiate-message workflow (spec — 2026-05-27)
+
+This section specs the two requests that matter most right now and the end goal that ties them together:
+
+1. Wire signel to keybindings.
+2. A contact picker keyed by *name*, not phone number, so initiating a chat (including a message to self) is a pick-from-names action.
+
+End goal: invoke a key, pick a contact by name, land in the chat buffer, type, send — the whole flow intuitive and without rough edges.
+
+** Current state (what's already built)
+
+- =cj/signal--parse-contacts= turns signal-cli =listContacts= output into a sorted =(LABEL . RECIPIENT)= alist, where LABEL is "Name (recipient)". Unit-tested against all 94 real contacts. This is the data layer for the name-based picker — done.
+- The notify-suppression helpers (=cj/signal--should-notify-p= and friends) and the fork wiring (=use-package signel=, private-config load) are in =modules/signal-config.el=.
+- =signel-chat= (signel.el) opens a chat buffer for a recipient but prompts with raw =(interactive "sSignal Recipient (+Phone): ")= — typing a phone number. Replacing that prompt with a name pick is the core of request #2.
+
+** Happy path
+
+1. =C-; M m= (or chosen key) invokes =cj/signel-message=.
+2. It ensures the daemon is connected, gets the contact list (cached), and runs =completing-read= over names, with "Note to Self" pinned first.
+3. Pick a name → resolve to recipient → call =signel-chat=.
+4. Chat buffer opens; type at the prompt; send.
+
+** Pieces to build
+
+In dependency order (the picker can't be built before the RPC result path exists — see Architecture additions below):
+
+1. *JSON-RPC success-result dispatch* (fork edit) — signel today routes only =receive= notifications and RPC errors; successful =((id . N) (result . VALUE))= responses have no path. Add a request-callback table and result routing. Everything else depends on this.
+2. =cj/signel--ensure-started= — the daemon/link/account guard predicate.
+3. =cj/signel--fetch-contacts= — issue =listContacts= via the new callback contract, feed the result through the existing parser, populate the cache.
+4. =cj/signel--contact-cache= + =cj/signel-refresh-contacts= — cj-owned picker cache, separate from signel's receive-time map.
+5. =cj/signel-message= — the interactive picker command wrapping =signel-chat=.
+6. =cj/signel-message-self= — direct "Note to Self" command.
+7. The signel =C-; M= prefix keymap.
+8. The #2 input-clobber fix (fork edit) covering both =signel--insert-msg= and =signel--insert-system-msg=, since both delete from the prompt line through =point-max=. A mid-type send must survive an incoming message AND a system-error insertion.
+
+** Decisions (resolved 2026-05-27 — Craig accepted all recommendations)
+
+Each recommendation below stands as the accepted decision, including D5 (the input-clobber fix is in scope for this workflow). The Options/Why are kept as the record of what was weighed.
+
+*** D1 — Keymap prefix and layout
+Options:
+- (a) =C-; M= ("Messages"), per the original Design note — =C-; S= is Slack, =C-; M= is free.
+- (b) =C-; G= ("siGnal").
+- (c) Fold into an existing comms prefix.
+
+Recommendation: (a) =C-; M=. Why: it's already reserved in the design note, "Messages" reads as the general intent (room to add other messaging later), and it dodges the Slack collision. Proposed leaf keys: =m= message (picker), =s= message-self, =d= dashboard, =l= link, =q= stop, =SPC= start/connect. (Final key list itself is low-stakes; the prefix is the real choice.)
+
+*** D2 — Contact-list freshness
+Options:
+- (a) Fetch live on every invocation.
+- (b) Cache on first use, refresh with an explicit command, auto-invalidate on (re)connect.
+- (c) Cache with a TTL.
+
+Recommendation: (b). Why: =listContacts= over the RPC isn't instant, and "intuitive" means the picker pops immediately. Cache-plus-explicit-refresh keeps it snappy and predictable; invalidating on connect covers the "I added a contact on my phone" case without a guessed TTL. A =cj/signel-refresh-contacts= command (bound under the prefix) handles the rare staleness.
+
+*** D3 — Message-to-self affordance
+Options:
+- (a) Pin "Note to Self" as the first entry in the picker.
+- (b) A dedicated =cj/signel-message-self= command on its own key.
+- (c) Both.
+
+Recommendation: (c) both. Why: message-to-self is a distinct, frequent intent (it's how you use Signal as a personal scratchpad), so a direct key is the fast path; the pinned picker entry covers discoverability for when you're already in the picker. Low cost to do both since both resolve to the same account recipient.
+
+*** D4 — Daemon not connected
+Options:
+- (a) Auto-start/connect the daemon, then proceed.
+- (b) Prompt "Signal isn't connected — connect now?" then proceed.
+- (c) =user-error= with a hint to run start/link.
+
+Recommendation: (a) when an account is linked, falling back to (c) when it isn't. Why: "intuitive" means the picker just works when you're set up, so auto-connecting on first use removes a manual step; but the client can't fabricate a link, so an unlinked state has to point you at =cj/signel-link= rather than hang.
+
+*** D5 — Is the input-clobber bug (#2) in scope here?
+Options:
+- (a) Fix it as part of this workflow.
+- (b) Track it separately, ship the picker + keymap first.
+
+Recommendation: (a) in scope. Why: your stated bar is "send a message without issues," and the clobber bug corrupts in-progress input the moment a message arrives mid-type — that is the send flow failing. The fork already plans this fix (Design → Folded-in upstream fix), and it sits right next to the notify edit. Shipping the picker while the clobber remains would meet the letter of request #2 but miss the end goal.
+
+*** D6 — 1:1 only, or groups in the picker?
+Options:
+- (a) 1:1 contacts only for now.
+- (b) Include groups in the same picker.
+
+Recommendation: (a) 1:1 only. Why: groups are an explicit Non-Goal for v1, and =listContacts= is the 1:1 source; pulling groups in means a second RPC (=listGroups=) and merged labels. Defer to a follow-up, consistent with the rest of the spec.
+
+** Architecture additions (resolving the 2026-05-27 review blockers)
+
+The Codex review (=docs/design/signal-client-review.org=) found the workflow above hid three unspecified architecture decisions. Confirmed against the fork: =signel--dispatch= (signel.el:230) handles only =receive= and =error=; a successful =result= response is dropped, and =signel--send-rpc= maps request IDs to buffers for error display only. These resolve those gaps so the build isn't inventing contracts midstream.
+
+*** JSON-RPC result path (blocker 1)
+The picker needs a value back from =listContacts=, which the fork can't currently deliver.
+- Add =signel--request-handler-map=, a hash keyed by JSON-RPC id holding a success callback.
+- Add =cj/signel--send-rpc-with-callback= (or extend =signel--send-rpc= with an optional success callback) that registers the callback under the request id.
+- Extend =signel--dispatch= to route =((id . N) (result . VALUE))= to the registered callback, and to clean up the handler entry on success, on error, and on reconnect (so a dead request can't leak a stale callback).
+- =cj/signel--fetch-contacts= consumes this: send =listContacts=, and in the callback parse + cache the result. Picker-facing failures surface as =user-error=; full RPC detail stays in =*signel-log*=.
+
+*** Daemon / link / account guard (blocker 2)
+"Auto-connect when linked, =user-error= when not" needs a real definition of "linked" and of process death.
+- =cj/signel--ensure-started= contract:
+ - Return normally when =(process-live-p (get-process signel--process-name))=.
+ - When =signel-account= is set but no live process exists, call =signel-start=.
+ - When =signel-account= is nil, =user-error= with the exact remedy (set it in the private config, or run the future link command — linking is out of scope this pass and done manually for now).
+ - If startup exits before the first RPC response, fail with a message pointing at =*signel-stderr*= / =*signel-log*= and the manual-link remedy, rather than hanging or surfacing a raw process error.
+- "Linked for v1" means: =signel-account= configured in =signal-config.local.el= AND =signal-cli -a ACCOUNT jsonRpc= starts a live process. The client does not separately prove the account is linked on the server; a not-actually-linked account fails at first RPC and routes through the startup-death message above.
+
+*** Contact cache ownership + invalidation (blocker 3)
+- =cj/signel--contact-cache= holds the parsed =(LABEL . RECIPIENT)= picker alist, owned by =signal-config.el=, kept separate from signel's =signel--contact-map= (which is receive-time sender names, a different and noisier source).
+- =cj/signel-refresh-contacts= clears and refetches it.
+- Auto-invalidate on reconnect by clearing =cj/signel--contact-cache= in the same wrapper/fork edit that starts or restarts the signel process.
+- An empty success result ("No Signal contacts returned") is a distinct, user-facing message from an RPC/startup failure; the two must not collapse into the same error.
+
+*** Note-to-Self recipient (medium)
+- v1 resolves "Note to Self" as =signel-chat= / =send= to =signel-account= (the linked number). No special-casing beyond pinning the picker entry and the direct command.
+- Manual-verify: sending to =signel-account= lands in the Signal Note-to-Self thread, not as a self-addressed display anomaly.
+
+*** Synchronous picker over asynchronous fetch (final blocker — resolved 2026-05-27)
+=completing-read= is synchronous; =cj/signel--fetch-contacts= is asynchronous via the callback table. On a cold cache the picker has to bridge that gap mid-call. Resolved via pre-warm + bounded block:
+- =cj/signel--ensure-started= triggers a background fetch on connect / restart. The fetch's callback populates =cj/signel--contact-cache=; no user-visible step.
+- =cj/signel-message= opens =completing-read= immediately when the cache is non-empty. On a cold cache (pre-warm hasn't returned yet), the command kicks off a fetch and calls =accept-process-output= with a bounded timeout (default 3s, =cj/signel-fetch-timeout= defcustom). On result, the picker opens. On timeout, =user-error= "Signal contact fetch timed out — try again, or refresh with =M-x cj/signel-refresh-contacts=" and point at =*signel-log*= for detail.
+- Why this shape: warm cache is the common path so the picker feels instant; cold path still completes without a two-step "fetching… try again" UX; the timeout prevents a dead or wedged daemon from hanging Emacs.
+
+*** Caveats accepted (state at build time, none blocking)
+- *JSON-RPC result envelope* — JSON-RPC 2.0 success is =((jsonrpc . "2.0") (id . N) (result . VALUE))=. The parser was verified on a real =listContacts= return on the live linked account, so the envelope keying is observed-correct in practice. Confirm against the next live response when the dispatch lands.
+- *Diagnostic logging stance* — =*signel-log*= (signel's existing log) carries RPC traffic, which includes contact names/numbers and message text. Single-user local setup, log lives on disk under Emacs's control: accept-and-state, no redaction beyond what signel already does. Revisit if the log ever gets synced off-machine or the threat model widens.
+- *Keymap conflict check* — before binding =C-; M=, verify it's unbound on the global =C-;= map at wiring time. The global =C-;= map is owned by =keybindings.el= (=cj/custom-keymap=); a quick =(keymap-lookup cj/custom-keymap "M")= during the keymap step is enough.
+
+** Testing
+
+Unit-testable without a live account (TDD these): the result-dispatch routing (a =result= response with a registered id invokes the callback; handler cleaned up on success/error; an unknown id is a no-op), the live-fetch result handling (mocked RPC JSON → parser, already covered for parsing itself), recipient resolution from a picked label, the note-to-self recipient, the daemon-state guard predicate (=cj/signel--ensure-started= branches: live process, account-set-no-process, account-nil), cache invalidation (refresh clears; empty result vs failure produce distinct outcomes), and *prompt-input preservation across both =signel--insert-msg= and =signel--insert-system-msg=* (regression for the #2 clobber fix and the system-error insertion path). Manual checklist against the linked account: the actual pick → open → type → send round-trip, the clobber fix under a real incoming message, the clobber fix under a real system-error insertion, auto-connect on first use, and that Note-to-Self lands in the right thread. This mirrors the Testing section above (pure helpers ERT, live loop manual).
+
+** Scope summary
+
+In scope: =cj/signel-message=, =cj/signel-message-self=, =cj/signel--fetch-contacts=, =cj/signel-refresh-contacts=, the JSON-RPC result-dispatch fork edit, =cj/signel--ensure-started=, the cj-owned contact cache + pre-warm, the =C-; M= keymap, and the #2 clobber fix. Out of scope for this pass: linking/QR (=cj/signel-link=, separate request), groups, and the colon-alignment-style polish. Linking is assumed already done manually for the workflow to be exercised.
+
+Notification-slice forward-flag: the existing Design notes route notifications through Craig's =notify= script with an optional sound, but the slice-level details — exact =notify= command shape, fallback when =notify= is missing, body truncation, and whether Signal message text is shown verbatim in desktop notifications — are not specified here. Before the notification slice starts, add a short subsection to this spec naming those four. Not in scope for the initiate-message workflow because the notify-suppression predicates already exist and the notification edit isn't on the build path for the picker.
+
+** Readiness rubric
+
+*Ready* (2026-05-27 spec-review). All three Codex blockers folded in (Architecture additions); the final sync/async decision resolved as pre-warm + bounded block; three minor caveats stated for build time, none blocking. Implementation order follows the Pieces-to-build list — the JSON-RPC result-dispatch fork edit is step 1, everything else builds on it.
+
+* Notification slice (spec addendum — 2026-06-11)
+
+Resolves the four details the forward-flag in Scope summary required before this slice starts. Craig accepted all four recommendations 2026-06-11.
+
+** The four decisions
+
+1. *Command shape.* =notify info "Signal: <sender>" "<body>"=, launched via =start-process= so the receive filter never blocks on the script. Type =info= (blue icon, confident tone). No =--persist=: messages arrive as a stream and persistent toasts pile up; a missed toast is still in the chat buffer, unlike an alarm. Sound follows =cj/signel-notify-sound= (defcustom, default nil): pass =--silent= unless it is non-nil.
+2. *Fallback when =notify= is missing.* Warn once at module load via =cj/executable-find-or-warn= (the established external-tool pattern), and at runtime fall back to the fork's =notifications-notify= call so an incoming message is never silently dropped on a machine without the script.
+3. *Body truncation.* Collapse whitespace runs (including newlines) to single spaces and truncate to 120 characters with an ellipsis. Long messages flood the toast and the daemon clips unpredictably; the full text is always in the chat buffer.
+4. *Verbatim text.* Show the (truncated) message text verbatim. Same accept-and-state stance as the =*signel-log*= caveat — single-user local machine. Revisit if notifications ever route off-machine.
+
+** Wiring architecture
+
+The fork stays generic; the policy lives in =signal-config.el=:
+
+- *Fork edit* — =signel.el= gains =signel-notify-function= (defvar, default =signel--notify-default= which preserves today's bare =notifications-notify= behavior). =signel--handle-receive='s notify block becomes =(funcall signel-notify-function chat-id sender notify-body)= — chat-id added so a custom function can make per-chat decisions. The fork never references cj/ symbols and remains loadable standalone.
+- *cj layer* — =cj/signel--notify= (set as =signel-notify-function= in the use-package =:config=) gates on =cj/signal--should-notify-p= (the existing suppression predicate), formats the body via a pure helper (=cj/signal--format-notify-body=: whitespace collapse + 120-char truncation), and routes: =notify= script when =executable-find= sees it, =notifications-notify= fallback otherwise.
+
+** Testing
+
+ERT, no live account: the body formatter (Normal/Boundary — passthrough, newline collapse, exact-limit, over-limit + ellipsis, empty, unicode); =cj/signel--notify= routing (suppressed when the predicate says no; script path args including =--silent= by default and its absence when sound is enabled; fallback path when the script is missing); the fork dispatch (a text message calls =signel-notify-function= with chat-id/sender/body; sticker → "[Sticker]"; attachment → "[Attachment]"; default function preserved). Manual: a real incoming message raises the toast through the script; no toast while viewing that chat in a focused frame.
diff --git a/docs/specs/theme-studio-package-faces-spec-doing.org b/docs/specs/theme-studio-package-faces-spec-doing.org
new file mode 100644
index 000000000..566f34db0
--- /dev/null
+++ b/docs/specs/theme-studio-package-faces-spec-doing.org
@@ -0,0 +1,590 @@
+:PROPERTIES:
+:ID: 8f37a1fd-cfd3-4b25-92e5-772468092bdc
+:STATUS: doing
+:END:
+#+TITLE: theme-studio — package faces (tier 3), starting with org-mode
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-07
+
+* Status
+
+Spec / Craig's first-round answers folded in (2026-06-07). Proposes a third tier
+for the theme-studio (scripts/theme-studio/) that lets a theme colorize
+package-specific faces, built one application at a time. v1 apps: org-mode
+(incl. org-agenda), magit, elfeed. Codex review incorporated (2026-06-07): added
+implementation phases, acceptance criteria, the package-face inventory source
+(hybrid, split), and state/export semantics. Rubric now =Ready=.
+All opens resolved (Craig, 2026-06-07/08): inheritance is modeled (show each
+face's resolved color in the table + preview, override what looks bad); inventory
+is hybrid-and-split (org/magit/elfeed bespoke first, generated all-package
+inventory as a later phase); the custom color picker is built after tier 3.
+Implementation tasks live in =todo.org=.
+
+* Background — the three tiers
+
+The theme-studio already models two tiers of faces:
+
+1. *Syntax* — the font-lock / tree-sitter categories (keyword, string, type,
+ comment, etc.), in the "code/color assignments" table.
+2. *UI* — Emacs's built-in interface faces (cursor, region, mode-line, fringe,
+ line numbers, isearch, and the rest), in the "ui faces" table with the live
+ mock-frame preview.
+
+Tier 3 is *package faces*: faces a package declares with =defface= so a theme
+can color the package as it wishes. The running config has 1,146 such faces
+across 186 packages (magit 111, lsp-mode 97, telega 91, web-mode 82, org ~30
+core, and a long tail). No theme colors all of them; quality themes hand-pick
+the packages the user actually lives in and theme those.
+
+This spec adds a tier-3 section to the tool, structured so applications are
+added one at a time. org-mode ships first.
+
+* Goal
+
+A new "package faces" section with:
+
+1. An *application dropdown* — pick which package's faces to edit. v1 ships
+ org-mode (including org-agenda), magit, and elfeed; the rest of Craig's
+ packages (calibredb, ghostel, mu4e, IRC, org-drill, dirvish + dired, slack)
+ follow one at a time.
+2. A *face table* for the selected app — one row per face in the app's complete
+ set, each with a foreground dropdown, a background dropdown, bold / italic
+ toggles, an optional inherit, and a relative-height stepper, all drawing from
+ the same palette as the other tables. Grouped, with a text filter for the
+ large apps.
+3. A *preview pane* for the selected app — a realistic mock of that package
+ rendered with the live theme, the way the ui-faces mock-frame shows the UI
+ faces in a buffer. org-mode gets a mock org document.
+
+The export (=theme.json=) gains a =packages= object so the build step can set
+these faces too.
+
+* UI placement
+
+A new top-level section under the ui-faces row:
+
+#+begin_example
+<h1>package faces</h1>
+[ application: (org-mode v) ]
+<div class="cols stretch">
+ left = the selected app's face table (fg / bg / B / I per face)
+ right = the selected app's preview pane (e.g. the org document mock)
+</div>
+#+end_example
+
+Same two-column stretch layout as the ui-faces row, so the preview matches the
+table's height.
+
+* Data model
+
+A single data structure drives everything, keyed by application:
+
+#+begin_src js
+APPS = {
+ "org-mode": {
+ label: "org-mode",
+ faces: [
+ // face, human label, default {fg, bg, bold, italic}
+ ["org-document-title", "document title", {fg:"gold", bold:true}],
+ ["org-level-1", "heading 1", {fg:"blue", bold:true}],
+ ["org-level-2", "heading 2", {fg:"gold"}],
+ ["org-level-3", "heading 3", {fg:"regal"}],
+ ["org-todo", "TODO keyword", {fg:"terracotta", bold:true}],
+ ["org-done", "DONE keyword", {fg:"sage", bold:true}],
+ ["org-link", "link", {fg:"blue"}], // base `link`
+ ["org-code", "inline code", {fg:"terracotta"}],
+ ["org-verbatim", "verbatim", {fg:"steel"}],
+ ["org-block", "src block body", {fg:"white", bg:"bg-dim"}],
+ ["org-block-begin-line","block delim", {fg:"pewter", bg:"bg-dim"}],
+ ["org-table", "table", {fg:"steel"}],
+ ["org-date", "timestamp", {fg:"steel"}],
+ ["org-tag", "tag", {fg:"tan"}],
+ ["org-special-keyword","keyword/drawer", {fg:"pewter"}],
+ ["org-meta-line", "#+meta line", {fg:"pewter"}],
+ ["org-checkbox", "checkbox", {fg:"gold"}],
+ ["org-headline-done", "done headline", {fg:"pewter"}],
+ ],
+ preview: "org" // names the preview renderer
+ },
+ // magit, elfeed, ... added later with the same shape
+}
+#+end_src
+
+Defaults reference palette *names* (blue, gold, ...) resolved to hexes at load,
+so a curated app seeds sensibly from the current palette. The user reassigns
+any face from the palette dropdowns exactly like the other tables.
+
+State mirrors the other tiers: a =PKGMAP= of
+={app: {face: {fg, bg, bold, italic, inherit, height, source}}}=, edited live, rendered into
+the table and the preview. The =APPS= block above shows ~18 org faces only as a
+shape illustration; the real org entry is the complete set below.
+
+** Data model — org face set (complete)
+
+Per the completeness decision, org's table lists org's entire own =defface= set
+(org-faces.el + org-agenda.el), ~88 faces, grouped. Seed defaults for the
+prominent groups; the long tail seeds to fg or an =inherit= of its group base,
+which the user overrides. The groups (face names verbatim from the running
+Emacs):
+
+- *Document:* org-document-title, org-document-info, org-document-info-keyword
+- *Headings:* org-level-1 .. org-level-8, org-headline-todo, org-headline-done
+- *Status / keywords:* org-todo, org-done, org-priority, org-tag, org-tag-group,
+ org-special-keyword, org-drawer, org-property-value, org-checkbox,
+ org-checkbox-statistics-todo, org-checkbox-statistics-done, org-warning
+- *Links / dates / refs:* org-link, org-footnote, org-date, org-sexp-date,
+ org-date-selected, org-target, org-macro, org-cite, org-cite-key
+- *Blocks / code / quote:* org-block, org-block-begin-line, org-block-end-line,
+ org-code, org-verbatim, org-inline-src-block, org-quote, org-verse,
+ org-latex-and-related
+- *Tables / columns:* org-table, org-table-header, org-table-row, org-formula,
+ org-column, org-column-title
+- *Lists / meta / structure:* org-list-dt, org-meta-line, org-ellipsis,
+ org-hide, org-indent, org-archived, org-default, org-dispatcher-highlight
+- *Agenda — structure & dates:* org-agenda-structure,
+ org-agenda-structure-secondary, org-agenda-structure-filter, org-agenda-date,
+ org-agenda-date-today, org-agenda-date-weekend, org-agenda-date-weekend-today,
+ org-agenda-current-time, org-agenda-done, org-agenda-dimmed-todo-face
+- *Agenda — calendar & filters:* org-agenda-calendar-event,
+ org-agenda-calendar-sexp, org-agenda-calendar-daterange, org-agenda-diary,
+ org-agenda-clocking, org-agenda-column-dateline, org-agenda-restriction-lock,
+ org-agenda-filter-category, org-agenda-filter-effort, org-agenda-filter-regexp,
+ org-agenda-filter-tags
+- *Scheduling / deadlines / clock:* org-scheduled, org-scheduled-today,
+ org-scheduled-previously, org-upcoming-deadline, org-upcoming-distant-deadline,
+ org-imminent-deadline, org-time-grid, org-clock-overlay, org-mode-line-clock,
+ org-mode-line-clock-overrun
+
+The org *preview* below stays a curated document exercising the prominent
+faces; the *table* carries the complete set so every face is assignable, even
+the ones the preview doesn't draw. magit and elfeed get the same treatment
+(complete own-defface set in the table, a bespoke preview for the common faces).
+
+* The org preview
+
+A mock org document painted from PKGMAP["org-mode"] plus the palette ground/fg.
+One bespoke renderer (=renderOrgPreview()=) drawing a representative document:
+
+#+begin_example
+#+TITLE: Project Notes <- org-document-title
+#+AUTHOR: ... <- org-meta-line / document-info
+
+* Inbox :work: <- org-level-1 + org-tag
+** TODO Draft the spec <- org-level-2 + org-todo
+ SCHEDULED: <2026-06-08 Sun> <- org-special-keyword + org-date
+** DONE Ship the tool <- org-level-2 + org-done (headline-done)
+*** Heading three <- org-level-3
+ A line with =inline code=, <- org-code
+ ~verbatim~, and a [[link]]. <- org-verbatim + org-link
+ - [X] a checkbox item <- org-checkbox
+
+ #+begin_src elisp <- org-block-begin-line
+ (message "hi") <- org-block
+ #+end_src <- org-block-end-line
+
+ | name | hex | <- org-table (header row org-table-header)
+ |------+---------|
+ | blue | #67809c |
+#+end_example
+
+Each marked element is a span colored from the corresponding PKGMAP face. The
+preview rebuilds whenever a package face or the palette changes, same as the
+mock frame.
+
+org, magit, and elfeed get bespoke preview renderers (magit -> a status buffer
+mock, elfeed -> a search-list mock). Every *other* package is still fully
+themeable: its face *table* is always present and editable, only the rich
+*preview* is replaced by a generic fallback — each face's name rendered in its
+own colors on the ground. So a user can theme every package they have the
+moment its face list is added; the bespoke preview is a polish layer on top, not
+a gate. This is the v1 answer to "some will want to touch every package."
+
+* Export schema
+
+=theme.json= gains a =packages= key:
+
+#+begin_src json
+{
+ "name": "dupre",
+ "palette": [...],
+ "assignments": {...},
+ "bold": [...], "italic": [...],
+ "ui": {...},
+ "packages": {
+ "org-mode": {
+ "org-level-1": {"fg":"#67809c","bg":null,"bold":true,"italic":false,"inherit":null,"height":1.3},
+ "org-level-2": {"fg":"#e8bd30","bg":null,"bold":false,"italic":false,"inherit":"org-level-1","height":1.2},
+ "org-todo": {"fg":"#cb6b4d","bg":null,"bold":true,"italic":false,"inherit":null}
+ }
+ }
+}
+#+end_src
+
+=inherit= is optional and =null= when absent. When set, the converter writes
+=:inherit PARENT= plus only the overridden attributes.
+
+Only faces the user actually touched (or the curated defaults) are written. The
+build step's converter sets each as a normal face. Backward compatible: a file
+without =packages= loads fine.
+
+* Build-step consumption
+
+The eventual =theme.json= -> =dupre-*.el= converter already owns tiers 1 and 2.
+Tier 3 adds, per package face:
+
+#+begin_src elisp
+(org-level-1 ((t (:foreground "#67809c" :weight bold))))
+(org-todo ((t (:foreground "#cb6b4d" :weight bold))))
+#+end_src
+
+No new converter machinery — package faces are just more faces. This is the
+TDD-worthy part (JSON in, valid faces out), same as the rest of the converter.
+
+* Scope for v1
+
+- Build the section, the app dropdown, and the face tables + previews for the
+ three v1 apps: org-mode (incl. org-agenda), magit, elfeed.
+- org's table carries its complete own-defface set (~88 faces, grouped above),
+ seeded with defaults; the org preview draws the prominent ones.
+- Every other installed package is reachable in the dropdown with an editable
+ face table and the generic fallback preview, so any package can be themed.
+- Wire export/import of the =packages= key (with the optional =inherit= and
+ =height= fields).
+- Leave the converter for the separate build-step task (Elisp, per Craig); the
+ spec only needs the schema to be right.
+
+* Implementation phases
+
+Phased so each step ships without a broken intermediate, and the three bespoke
+apps don't wait on the all-package inventory.
+
+1. *State + schema.* Add =PKGMAP=
+ ({app:{face:{fg,bg,bold,italic,inherit,height,source}}}) and the =APPS=
+ registry. Extend export/import with the =packages= key; old JSON (no
+ =packages=) still imports cleanly. No UI yet.
+2. *Curated app data.* Complete own-defface face lists + seeded defaults for org
+ (incl. org-agenda), magit, elfeed, in =APPS= — including heading heights and
+ the fixed-pitch inherits. Pure data.
+3. *Package face table UI.* App selector; grouped rows; fg/bg dropdowns + bold /
+ italic toggles + optional inherit + a relative-height stepper; per-face and
+ per-app reset; a text filter (org/magit are large); a contrast readout per
+ fg/bg. Built on a generalized face-control helper shared with the ui-faces
+ table, not a fork of =uiSelect=.
+4. *Org preview.* =renderOrgPreview()=, live, refreshing on palette/face change.
+5. *Magit + elfeed previews.* Bespoke mocks (magit status buffer, elfeed search
+ list).
+6. *Generated all-package inventory* (the "theme every package" path). A build
+ step queries Emacs for installed packages' faces grouped by package, writes a
+ data file =generate.py= embeds; the dropdown then lists every package with an
+ editable table + the generic fallback preview. Lands after phases 1-5 without
+ blocking the three bespoke apps.
+7. *Docs + validation.* README =packages= schema + inventory-refresh command;
+ regenerate HTML; fixtures + manual checklist.
+
+Phases 1-5 deliver the three high-value apps fully; phase 6 opens the long tail;
+phase 7 documents.
+
+* Package face inventory source
+
+*Hybrid, split across phases.* Curated app metadata (org/magit/elfeed: complete
+face lists, seeded defaults, bespoke previews) is hand-maintained in =APPS= and
+ships in phases 2-5. A *generated* =PACKAGE_FACE_INVENTORY= — produced by a build
+step that asks the running Emacs for each installed package's faces grouped by
+package, written to a JSON/Python data file =generate.py= embeds — supplies the
+generic fallback packages and ships in phase 6.
+
+Why hybrid and split: the static generator can't discover packages at runtime in
+the browser, so "theme every package" needs a generated inventory; but making the
+full inventory a prerequisite for the three bespoke apps invites the scope
+explosion the review flagged. Splitting it lets v1's core ship first; the
+inventory is additive.
+
+The generated inventory is an *input artifact* to =generate.py= (a committed data
+file refreshed by an explicit command), never browser-side discovery. The refresh
+command's dependency on a loaded Emacs config is documented.
+
+Decided (Craig, 2026-06-08): hybrid-and-split, as above.
+
+* State and export policy
+
+Each package face object carries a =source= marker so export can tell a seeded
+default from a user edit from a deliberate clear:
+
+#+begin_src js
+{ fg:"#67809c", bg:null, bold:true, italic:false, underline:false, strike:false, inherit:null, height:1.0, source:"default" }
+// underline / strike: booleans -> the converter writes :underline t / :strike-through t
+// height: float multiplier off the base font (1.0 = unchanged); see Relative height
+// source: "default" (seeded) | "user" (edited) | "cleared" (user removed a default)
+#+end_src
+
+Export policy:
+
+- Write =default= and =user= entries.
+- Write =cleared= entries — they must suppress a curated default on reload.
+- Omit untouched faces that have no default.
+- When =inherit= is set, write =inherit= plus only the explicit overrides.
+- Write =height= only when it differs from 1.0.
+- Preserve package faces present in an imported file but absent from the current
+ inventory (or warn) — don't silently drop them.
+
+Import tolerates a missing =packages= key, unknown app keys, unknown face keys,
+a missing =inherit=, and a missing =height= (defaults 1.0). A deleted palette
+color leaves package face references in the same "(gone)" recoverable state
+syntax colors use. Inheritance cycles are rejected (treated as no inheritance)
+during preview resolution.
+
+* Relative height
+
+Some faces want to be bigger than body text — org headings above all, also
+=org-document-title=. A face's =height= field is a *float multiplier* off the
+base font (=1.3= = 1.3× the running font, whatever it is), never an absolute
+point size, so it stays portable across fonts and machines. =1.0= means
+unchanged. The base monospace family is *not* a theme/tool concern — it lives in
+=modules/font-config.el=; the tool owns only relative size.
+
+*Height does not cascade through =inherit=.* This is the one attribute resolved
+directly off the face, not through its inherit chain. Emacs multiplies float
+heights along an inherit chain, so a level-2 that inherits level-1 (1.3) and
+also sets 1.1 would render at 1.43 — almost never what's wanted. Headings should
+each size off the *body*, so the seeded defaults set =org-level-1= 1.3,
+=org-level-2= 1.2, =org-level-3= 1.15, etc., each independent, and the tool reads
+=height= from the face while still resolving *color* through inherit.
+
+- *Schema:* the =height= float on the face object (above), default 1.0, omitted
+ from export when 1.0.
+- *UI:* a small numeric stepper in the face row (range ~0.8–2.0, step 0.05);
+ meaningful only for the size-bearing faces but shown on every row at 1.0.
+- *Preview:* the row renders at the scaled =font-size= so a heading visibly
+ grows in the mock.
+- *Converter:* writes =:height 1.3= into the face spec when ≠ 1.0.
+
+Related, same mechanism: org's mixed-pitch faces (=org-block=, =org-code=,
+=org-verbatim=, =org-table=, =org-meta-line=, =org-date=) seed =inherit:
+"fixed-pitch"= so they stay monospace when a buffer switches to a proportional
+font via =variable-pitch-mode= / =mixed-pitch=. The proportional family itself
+stays in =font-config.el= (the presets already carry =:variable-pitch-family=);
+the tool only carries the fixed-pitch inherit relationship, shown like any other
+inherited value.
+
+* Acceptance criteria
+
+- Existing =dupre.json= (no =packages= key) imports cleanly.
+- Export includes =packages= once defaults or edits exist;
+ =fg/bg/bold/italic/inherit/height/source= round-trip through import/export.
+- A face =height= renders as a scaled font-size in the preview (heading visibly
+ grows) and is read off the face, not cascaded through =inherit=.
+- org, magit, elfeed appear in the app selector with complete grouped face tables.
+- (phase 6) generic inventory packages appear with editable tables + fallback
+ previews, the fallback visibly labeled as generic.
+- A palette color update propagates to package faces the same way it does to
+ syntax / ui faces.
+- =python3 scripts/theme-studio/generate.py= rebuilds =theme-studio.html=.
+- README documents the =packages= schema, inheritance, and the inventory source.
+
+* Extensibility (adding the next app)
+
+1. Add an entry to =APPS= (label, curated face list with palette-name defaults,
+ preview key).
+2. Optionally write a bespoke preview renderer; until then the generic fallback
+ renders.
+3. Nothing else changes — the dropdown, table, export, and import are all
+ data-driven off =APPS= / =PKGMAP=.
+
+* Agreed decisions
+
+Craig's answers to the first review round, baked in (the body sections above
+reflect these; this records the decisions):
+
+1. *Curated set is complete, not iterative.* For org, list its *entire* own
+ defface set (org-faces.el + org-agenda.el), ~88 faces, not a hand-picked
+ ~18. The user wants every choice present, not a set that grows on demand.
+ See "Data model — org face set" for the full grouped list.
+2. *Seed curated defaults.* Seed sensible fg/bg and weight per face (headings,
+ title, TODO/DONE bold; agenda dates and deadlines colored by role). The user
+ reassigns from there.
+3. *App order: org, magit, elfeed for v1.* Then the rest one at a time, drawn
+ from the packages Craig actually runs: calibredb, ghostel, mu4e, the IRC
+ client, org-drill, dirvish + dired, slack. A finite "most-used" list gets
+ picked later; we do not try to do everything at once.
+4. *Generic fallback is real, not display-only.* Any package not given a
+ bespoke preview still gets a fully editable face table (so a user can theme
+ *every* package they have); only the rich preview is missing, replaced by a
+ swatch-in-context fallback. Bespoke previews ship for org, magit, elfeed.
+
+* Inheritance representation (decided)
+
+Each face carries an optional =inherit= field naming another face (or =null=).
+The face's own =fg/bg/bold/italic= are *overrides* layered on top of what it
+inherits.
+
+#+begin_src js
+["org-level-2", "heading 2", {inherit:"org-level-1", fg:"gold"}]
+// exports as: (org-level-2 ((t (:inherit org-level-1 :foreground "#e8bd30"))))
+["org-agenda-date-today", "agenda today", {inherit:"org-agenda-date", bold:true}]
+// exports as: (org-agenda-date-today ((t (:inherit org-agenda-date :weight bold))))
+#+end_src
+
+*Decision (Craig, 2026-06-07): model inheritance, show the resolved result,
+override what looks bad.* The point is to see what a face ends up looking like
+when it inherits, judge it in the preview, and fix only the ones that look
+wrong:
+
+- Each face's *effective* color is resolved through its inherit chain and shown
+ in its table row, visibly marked "inherited from <face>" so it reads as
+ not-explicitly-set. The face's own =fg/bg/bold/italic= are overrides layered
+ on top.
+- The mock preview on the right renders every face with its effective color, so
+ inherited faces are judged in context, not in the abstract.
+- Overriding is one action: assign a color (or toggle weight) and the row flips
+ from inherited to explicit (=source: "user"=), shown at once in the table and
+ preview.
+- Export writes =:inherit PARENT= for faces left inherited (carrying the
+ relationship, so they follow the parent the theme also sets) and explicit
+ attributes for the ones overridden — never a frozen copy of an inherited
+ color.
+
+Seeded defaults express the inherit relationships org itself uses out of the box
+(heading levels off a base, =org-agenda-date= variants off =org-agenda-date=,
+=org-code= / =org-verbatim= off =fixed-pitch=), so the table opens showing
+org's real cascade, which the user then tunes. Inheritance cycles resolve to no
+inheritance.
+
+* Custom color picker (proposal)
+
+Craig wants a custom in-page color picker to replace the native browser swatch.
+The native =<input type=color>= opens the OS color chooser, which the page
+cannot size or restyle; a custom picker is the only way to get a larger,
+on-theme picker and to show the palette/contrast in the picker itself.
+
+Proposed widget — a popup anchored to the swatch, drawn in-page:
+
+- A *saturation/value square* (click or drag to set S and V) plus a *hue
+ slider* down the side. Standard HSV picker geometry.
+- A *hex field* synced both ways with the square/slider (already exists in the
+ add-color row; the picker writes to it).
+- The current *palette* shown as clickable chips along the bottom, so picking
+ an existing color is one click and the overlap problem (many roles, one
+ color) is visible while choosing.
+- A live *contrast readout* against the current background (ratio + AAA / AA /
+ FAIL) updating as the color moves, so a color is judged for legibility at
+ pick time, not after assignment.
+- Sized generously (the native popup's size was the original complaint); opens
+ on click of the swatch, closes on pick or click-away.
+
+Implementation: ~120 lines of vanilla JS/canvas (or CSS gradients) for the
+square + slider, reusing the existing =rl()= / =contrast()= / =rating()=
+helpers for the readout and =normHex()= for the field sync. No dependency. It
+replaces the =<input type=color>= in the add-color row and, later, becomes the
+picker the package-face dropdowns can also invoke.
+
+It stays *off* the tier-3 critical path: a separate task before or after the
+package-face build, not folded into it, since folding it in widens the blast
+radius for no dependency benefit. Build it only sooner if package-face editing
+proves painful with the native swatch.
+
+Decided (Craig, 2026-06-08): after tier 3, as its own task.
+
+* Files touched
+
+- =scripts/theme-studio/generate.py= — the section, =APPS= data, the package
+ face table, =renderOrgPreview()=, export/import of =packages=.
+- =scripts/theme-studio/theme-studio.html= — regenerated.
+- (later) the =theme.json= -> =dupre-*.el= converter (Elisp) — consumes
+ =packages=.
+
+* Review dispositions
+
+Codex review (2026-06-07), =Not ready=. Findings processed:
+
+- *Modified — generated inventory (high, blocking).* Codex recommended a hybrid
+ inventory so every installed package is reachable. Accepted the hybrid, but
+ *split* it: the generated all-package inventory is its own phase (6), after the
+ three bespoke apps (phases 1-5), rather than a v1 prerequisite. Reason: Codex
+ named scope explosion as the main risk, and gating org/magit/elfeed on a
+ full-inventory mechanism is exactly that. The split keeps v1's core shippable
+ and makes "theme every package" additive. Confirm-with-Craig flagged as an
+ open.
+- *Modified — preview depth (UX obs).* Codex suggested level 4-8 examples in the
+ org preview. The preview stays a curated document drawing the prominent faces
+ (incl. a couple of deeper levels as representative); the complete level set
+ lives in the *table*, which is where every face is assignable. A full 8-level
+ preview block would bloat the mock without adding assignability.
+
+Everything else in the review accepted as written: implementation phases,
+acceptance criteria, the =source= state field + export policy, curated-vs-complete
+wording, keeping the custom picker off the critical path, unknown-import
+preservation, the test-strategy fixtures, and the UX/architecture/robustness
+observations (grouping + filter, reset controls, package-fg/bg contrast readout,
+generalized face-control helper, package style kept inside the package object,
+"(gone)" recoverable state, inheritance-cycle rejection).
+
+* Review and iteration history
+
+** 2026-06-07 Sun @ 18:17:14 -0500 — Claude Code (emacs-d) — author + responder
+- *What:* Folded Craig's first-round cj-comment answers into the body. Curated
+ org set changed from ~18 to org's complete own-defface set (~88, grouped, incl.
+ org-agenda). v1 apps fixed to org/magit/elfeed with the rest deferred to a
+ one-at-a-time list. Generic fallback clarified as a fully editable table for
+ every package (only the rich preview is bespoke). Answered the inheritance
+ question with an optional =inherit= field (absolute-default, opt-in cascade)
+ and added it to the schema. Added a custom-color-picker proposal at Craig's
+ request. Converted Open questions to Agreed decisions; two genuine opens
+ remain (inheritance confirm, picker timing).
+- *Why:* Craig answered the five open questions inline and asked for the
+ spec-response fold plus a custom-picker proposal. Completeness over iterative
+ growth was his explicit call on the curated set.
+- *Artifacts:* This spec. The converter language (Elisp) confirmed by Craig.
+ Note: Craig referenced a Codex spec review, but no
+ =theme-studio-package-faces-review.org= (or any =*-review.org=) exists in the
+ repo — the review file could not be found, so only the author-side cj-comment
+ fold was done; the Codex review remains to be processed when its file is
+ located.
+
+** 2026-06-07 Sun @ 18:20:16 -0500 — Codex — reviewer
+- *What changed or was recommended:* Created the missing formal review and
+ marked the spec =Not ready=. The review recommends adding implementation
+ phases, acceptance criteria, an explicit package-face inventory source, and
+ default/user/cleared/inherited export semantics.
+- *Why:* The direction is coherent and the first-round decisions are folded in,
+ but v1 now depends on behavior that is not yet implementable from the current
+ static generator without a defined inventory and state/export contract.
+- *Artifacts:* [[file:theme-studio-package-faces-spec-review.org][theme-studio-package-faces-spec-review.org]]
+
+** 2026-06-07 Sun @ 18:28:02 -0500 — Claude Code (emacs-d) — responder
+- *What:* Ran spec-response against the Codex review. Added Implementation
+ phases (7), Package face inventory source (hybrid, split), State and export
+ policy (=source= field + export rules), and Acceptance criteria. Wove in the
+ UX/architecture/robustness observations. Two reasoned modifies (generated
+ inventory split into its own phase; org preview stays curated rather than
+ all-8-levels), everything else accepted — see Review dispositions. Status
+ moved to =Ready with caveats=. Deleted the review file.
+- *Why:* The four blocking findings were real implementation-contract gaps; the
+ inventory split answers Codex's own scope-explosion warning while still
+ reaching "theme every package."
+- *Artifacts:* This spec (Review dispositions section); review file deleted per
+ the spec-response close-out. Three opens remain for Craig (inheritance confirm,
+ hybrid-inventory confirm, picker timing).
+
+** 2026-06-08 Mon @ 00:12:38 -0500 — Codex — reviewer
+- *What changed or was recommended:* Reran spec-review after the response pass.
+ Assigned rubric =Ready= and did not create a new review file. The previous
+ blockers are now addressed: implementation phases, acceptance criteria,
+ hybrid/split inventory source, package-face state/export semantics, task
+ tracking, and the open inheritance/inventory/picker decisions are resolved.
+- *Why:* The spec now gives an implementer concrete behavior, phase boundaries,
+ validation criteria, and deferred-work handling without forcing product
+ decisions during implementation.
+- *Artifacts:* This spec; implementation tasks in [[file:../../todo.org][todo.org]].
+
+** 2026-06-08 Mon @ 00:38:23 -0500 — Claude Code (emacs-d) — author
+- *What:* Added a relative =height= field to the face schema (float multiplier
+ off the base font, default 1.0, omitted at 1.0), a new "Relative height"
+ section, a per-face stepper in the table, preview scaling, and converter
+ output. Established the rule that =height= is read off the face and does *not*
+ cascade through =inherit= (Emacs multiplies float heights along the chain).
+ Noted the mixed-pitch =fixed-pitch= inherits as the same-mechanism companion.
+ Brought Phase 1's shipped schema plumbing in line with the new field.
+- *Why:* Craig asked to fold height in — it matters for org headings above all.
+ Font *family* stays in =modules/font-config.el=; the theme owns relative size
+ and the fixed-pitch inherit relationships only.
+- *Artifacts:* This spec; =scripts/theme-studio/generate.py= phase-1 plumbing.
diff --git a/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org b/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org
new file mode 100644
index 000000000..57a4c70bc
--- /dev/null
+++ b/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org
@@ -0,0 +1,580 @@
+:PROPERTIES:
+:ID: 15db8ae3-fc14-49f3-9ed5-d5ff59790904
+:STATUS: implemented
+:END:
+#+TITLE: theme-studio — perceptual color metrics (OKLCH, APCA, ΔE)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-08
+
+* Status
+
+Spec / review incorporated (Codex, 2026-06-08, two passes). Adds perceptual color metrics to
+the theme-studio (=scripts/theme-studio/=) so it can build deliberately
+low-contrast themes (Solarized / Zenburn class) with the same rigor it already
+brings to high-contrast WCAG checking. Four additions: an OKLCH color model, a
+per-color perceptual-lightness readout, an APCA contrast score alongside the
+existing WCAG ratio, and a pairwise ΔE distinguishability check across the
+palette.
+
+Came out of a design conversation comparing the low-contrast school (Solarized,
+Zenburn) against Prot's high-contrast Modus themes. The conclusion: a theme has
+three independent dials — contrast ratio, overall luminance, and chroma — and
+the low-contrast camp turns down the first while Modus leaves it high and turns
+down the other two. The current tool only measures the first (WCAG contrast) and
+edits color in HSV, whose "lightness" is not perceptually uniform. To build
+low-contrast themes by metric rather than by eye, the tool needs
+perceptually-uniform lightness and chroma controls plus distinguishability and
+polarity-aware contrast measures.
+
+Rubric: *Ready.* The four v1 product questions are decided in "Agreed decisions
+(v1)" below and confirmed by Craig (2026-06-08); the testing strategy was
+revised on his direction to a layered pyramid (Node-unit-tested color core +
+thin UI hash tests + measured coverage). No remaining blocking ambiguity — the
+implementer no longer has to invent product behavior while coding. Implementation
+is sequenced into five phases, each independently shippable and tested. Tasks
+filed in =todo.org=.
+
+* Background — the current color model
+
+The tool today works entirely in sRGB hex, HSV, and WCAG luminance. The relevant
+cluster in =generate.py=:
+
+- =rl(hex)= (line 517) — WCAG relative luminance, via the existing =lin()=
+ sRGB-linearization helper.
+- =contrast(a,b)= (line 519) — the WCAG 2.x ratio =(L1+0.05)/(L2+0.05)=.
+- =rating(r)= / =ratingColor(r)= (lines 520-521) — AA (≥4.5) / AAA (≥7) verdict
+ and its display color.
+- =hsv2rgb=, =rgb2hsv=, =hex2rgb=, =rgb2hex=, =normHex= (lines 604-609).
+- The picker holds state as =pkH, pkS, pkV= (HSV) and renders an SV box (=#sv=),
+ a hue strip (=#hue=), crosshairs (=#svcur=, =#huecur=), a hex+contrast readout
+ (=#pkhex= / =#pkcon=, line 451), the contrast-mask buttons (=.pmode=, state
+ =pkMode=, values =any= / =aa= / =aaa=), and palette chips (=#pkchips=).
+- =drawMask()= (line 613) greys SV-box regions whose contrast against the
+ background falls below the selected mask threshold (=pkThresh()=).
+- Per-face contrast readouts appear across *three* tables — syntax (line 548),
+ UI (line 1064), and package faces (line 752) — each via =contrast()= +
+ =rating()=. The package-face tier has grown large since the tool's early
+ versions (51 packages in the current inventory), so any "add a column to the
+ table readouts" change now touches that whole surface, not just the two
+ original tables.
+
+Two limitations this spec addresses:
+
+1. *HSV lightness lies.* Two colors at the same HSV =V= can differ markedly in
+ perceived brightness, so the SV box cannot hold perceived lightness constant
+ while hue changes — exactly the operation a calm, even palette needs.
+2. *WCAG 2.x is a known-flawed contrast model in the low-contrast / dark band.*
+ Its ratio misjudges contrast most where this work operates, and it is not
+ polarity-aware: it scores light-on-dark and dark-on-light identically, which
+ perception does not. WCAG 3 is reworking contrast but is years out — still a
+ Working Draft in 2026, with the final Recommendation not expected until
+ roughly 2028–2030 — and its contrast algorithm is undetermined: APCA was moved
+ *out* of the WCAG 3 draft in 2023 for further evaluation. So APCA enters here
+ as a well-regarded independent perceptual model used as an additional
+ diagnostic, not as a coming standard. WCAG 2.x stays the baseline precisely
+ because nothing has replaced it yet.
+
+* Goal
+
+Add four metrics, each a discrete increment:
+
+1. *OKLCH color model* — perceptually-uniform Lightness / Chroma / Hue, so the
+ editor can move one axis without disturbing the others, plus a gamut clamp
+ for OKLCH values outside sRGB.
+2. *Perceptual-lightness readout* — show each color's OKLCH L (and C, H) in the
+ picker, so "low, even lightness steps" becomes a number rather than a guess.
+3. *APCA score* — the Accessible Perceptual Contrast Algorithm Lc value
+ displayed next to the WCAG ratio, as the more trustworthy contrast metric in
+ the low-contrast band.
+4. *Pairwise ΔE check* — perceptual color-difference between every pair of
+ palette entries, flagging pairs too similar to tell apart, which is the
+ constraint that keeps a low-chroma / low-lightness-spread palette from
+ collapsing into mush.
+
+Non-goals: replacing WCAG (it stays as the compatibility baseline, shown
+alongside APCA, which is an additional perceptual diagnostic, not a
+replacement); replacing the HSV picker outright (OKLCH is added as a parallel
+color model, HSV remains the default); CIEDE2000 in v1 (ΔE-OK is the v1
+difference metric — see vNext).
+
+* Agreed decisions (v1)
+
+Settled on author + reviewer alignment and confirmed by Craig (2026-06-08).
+
+1. *ΔE metric and threshold.* Use ΔE-OK (Euclidean distance in OKLab) on its
+ native scale (OKLab L is 0..1). Default "too similar" warning threshold is
+ *0.02* — the just-noticeable-difference floor, so the warning fires only when
+ two palette colors are genuinely hard to tell apart. The threshold is a named
+ constant, calibratable in one place. CIEDE2000 (the CIE's 2000 perceptual
+ color-difference standard — more accurate than plain Euclidean distance, but
+ ~40 lines of trigonometric lightness/chroma/hue corrections plus a blue-region
+ rotation term) is deferred to vNext: ΔE-OK is accurate enough to flag
+ indistinguishable pairs, which is all this check needs, and it is five lines.
+2. *Low-contrast preset.* v1 ships *readouts only* (OKLCH, APCA, ΔE). No named
+ low-contrast preset / mask mode yet. No such preset exists anywhere today — it
+ would be a new feature: a saved low-contrast target (e.g. an APCA Lc band, or
+ a contrast ceiling as well as a floor) that masks the palette to a comfortable
+ range in one click, the way the current any/AA+/AAA buttons mask by a contrast
+ floor. It is deferred until the raw readouts are in use, because only then is
+ it clear which band is worth presetting. v1 gives the numbers; the preset
+ would automate a judgment the numbers first have to inform.
+3. *APCA placement.* v1 shows APCA *only in the picker* readout, not in the
+ syntax/UI/package table contrast cells. Adding it to the tables is
+ low-complexity once =apca()= exists — the same pattern as the existing
+ =contrast()= + =rating()= cells, repeated across the three tables — so the
+ deferral is about table *density*, not difficulty: the package table alone is
+ 51 packages wide, and a second contrast number per row risks clutter before
+ it is clear anyone reads it there. Table-wide APCA is a vNext candidate if
+ picker-only proves too hidden.
+4. *Picker default model.* HSV stays the *default* picker model; OKLCH is
+ opt-in via a color-model control. The reason: HSV is the familiar 2D SV-box
+ the picker already has, and OKLCH is slider-only until the C×L plane (Phase
+ 4b) lands — so defaulting to OKLCH before 4b would hand users a worse default
+ editing surface than they have now. Once 4b ships the C×L plane, making OKLCH
+ the default becomes a real option worth revisiting; until then, HSV default
+ keeps the current editing experience intact and makes OKLCH an additive
+ choice, not a regression.
+
+* Color-math foundation (Phase 1, prerequisite)
+
+The pure color math is *extracted into its own importable module* rather than
+inlined as loose functions in the page. This is the core architectural change
+this spec makes to the test surface: the math is logic, so it gets tested as
+logic — directly, in Node, with exhaustive fixtures — and the picker becomes a
+thin UI layer over a tested core, not the only way to exercise the math.
+
+- New file: =scripts/theme-studio/colormath.js= — the pure, side-effect-free
+ conversion + metric functions, written as an ES module (each =export=-ed),
+ with a small guard so the same source loads both ways: =import=-ed by the Node
+ tests and spliced into the page by the generator.
+- =generate.py= inlines =colormath.js= into the page's =<script>= the same way
+ it already inlines =samples.py='s data, so there is *one source of truth* — the
+ exact code the browser runs is the code the tests import. An inline-integrity
+ check (see Verification strategy) asserts the page contains the module verbatim
+ so the two can never drift.
+- The existing inline helpers it supersedes (=lin=, =rl=, =contrast=, =rating=,
+ =hsv2rgb=, =rgb2hsv=, =hex2rgb=, =rgb2hex=) move into =colormath.js= too, so the
+ whole color core lives and is tested in one place. =normHex= stays at the UI
+ boundary; module functions assume a normalized =#rrggbb= and the Node tests
+ cover their edges directly.
+
+The functions (standard published algorithms):
+
+- =srgb2oklab(hex)= / =oklab2srgb(L,a,b)= — Björn Ottosson's OKLab matrices
+ (2020). sRGB → linear (reuse =lin()=) → LMS → cube-root → OKLab, and the
+ inverse. ~20 lines.
+- =oklab2oklch(L,a,b)= / =oklch2oklab(L,C,H)= — Cartesian↔polar: =C=√(a²+b²)=,
+ =H=atan2(b,a)=. Trivial.
+- =oklch2hex(L,C,H)= with the *gamut clamp* (see below). Returns
+ ={hex, clamped}= — the in-gamut hex plus a boolean flag.
+- =apca(textHex, bgHex)= — the APCA-W3 algorithm. Returns a signed Lc (positive
+ for dark-text-on-light, negative for light-text-on-dark; magnitude ~0–107).
+- =deltaE(aHex, bHex)= — ΔE-OK: Euclidean distance in OKLab,
+ =√((ΔL)²+(Δa)²+(Δb)²)=. Five lines.
+
+** Gamut clamp policy (v1, fixed)
+
+OKLCH can express colors sRGB cannot show (high C at some L/H). The v1 policy is
+*binary-search chroma reduction*: hold L and H fixed, reduce C until the color
+is in sRGB gamut. This preserves the two perceptual axes the user is reasoning
+about and only sacrifices saturation. Component clipping (which can shift all
+three axes and make a slider feel broken) is explicitly *not* used.
+
+=oklch2hex= returns ={hex, clamped}= where =clamped= is true when chroma was
+reduced. The picker keeps its sliders and readouts on the *actual clamped color*
+after conversion, and shows a short status ("chroma clamped to sRGB") when
+=clamped= is true — so the user never sees an axis silently move.
+
+** APCA source (pinned)
+
+Implement against *APCA-W3 0.1.9* (Myndex), transcribing the constants verbatim:
+
+- Source: =https://github.com/Myndex/apca-w3= (the =apca-w3= package, version
+ 0.1.9). The implementation puts this URL + version in a code comment beside
+ =apca()=.
+- Screen luminance per color uses the *exact* APCA-W3 0.1.9 =colorSpace=
+ constants, not rounded values: =Ys = 0.2126729·R^2.4 + 0.7151522·G^2.4 +
+ 0.0721750·B^2.4= on the 0..1 sRGB channels (straight 2.4 power, not the WCAG
+ piecewise). All remaining APCA constants — the black soft-clamp
+ (=blkThrs=/=blkClmp=), the polarity-specific text/background exponents
+ (=normBG=/=normTXT=/=revTXT=/=revBG=), the low-contrast roll-off
+ (=loBoThresh=/=loBoFactor=/=loClip=), =deltaYmin=, and =scaleBoW= — are
+ likewise transcribed verbatim from the pinned source. The spec does not restate
+ those numbers, to avoid becoming a second, drift-prone source: the pinned
+ =apca-w3= 0.1.9 is the single authority.
+- Fixture values asserted by the Node unit tests: =apca('#000000','#ffffff')=
+ Lc ≈ *106.0* (dark on light, positive); =apca('#ffffff','#000000')= Lc ≈
+ *-107.9* (light on dark, negative); plus at least one *chromatic* APCA fixture
+ (e.g. =apca('#67809c','#ffffff')=) computed from the pinned reference —
+ black/white alone cannot reveal rounded-coefficient drift, since the rounding
+ error is near zero at the channel extremes.
+
+The tool ships as a single self-contained generated HTML file with no runtime
+build step or package manager, so the APCA algorithm is transcribed into
+=colormath.js= (inlined into the page) rather than vendored as an npm dependency.
+The Node test harness is dev-only — it imports =colormath.js= to assert against
+fixtures — and does not make the shipped artifact depend on Node or any package.
+
+** Verification — Node unit tests (=test-colormath.mjs=)
+
+The math is tested *directly*, not through the browser: =scripts/theme-studio/test-colormath.mjs=
+imports =colormath.js= and asserts against fixtures under =node --test=. No DOM,
+no Chrome, sub-second, and not capped by what the UI happens to exercise — this
+is where the bulk of the feature's test value lives, and it can be far more
+exhaustive than a hash test. It must include *chromatic* fixtures and properties,
+because many incorrect matrix/sign implementations still pass black, white, and
+round-trip:
+
+- =srgb2oklab('#ffffff')= L ≈ 1.0, a ≈ 0, b ≈ 0; =srgb2oklab('#000000')= L ≈ 0.
+- chromatic fixture 1 — saturated red =#ff0000=: OKLab/OKLCH within epsilon of
+ the reference (L ≈ 0.628, C ≈ 0.258, H ≈ 29.2°).
+- chromatic fixture 2 — the dupre blue =#67809c=: OKLCH ≈ (L 0.591, C 0.052,
+ H 252°), epsilon ~0.005 on L/C and ~1° on H. Computed from the Ottosson
+ reference; the implementation verifies against the same reference it
+ transcribes.
+- round-trip *property*: for a generated sample of hexes,
+ =oklch2hex(oklab2oklch(srgb2oklab(h))).hex= ≈ =h= within epsilon. A property
+ test over random inputs, not a fixed list — it explores corners a hand-written
+ list would miss.
+- =apca= both polarities against the pinned fixtures above (assert sign and
+ magnitude), plus the chromatic APCA fixture.
+- =deltaE(h,h)= = 0; =deltaE('#000000','#ffffff')= > 0; ordering: a near pair
+ scores below the 0.02 threshold, a well-separated pair above it.
+- gamut clamp: a known out-of-gamut OKLCH (very high C) returns a valid
+ =#rrggbb= with L and H preserved within epsilon, C reduced, and =clamped=
+ true; an in-gamut input returns =clamped= false unchanged.
+
+Pure-function TDD with no rendering dependency: write the failing Node test,
+confirm it FAILs (e.g. with a deliberately wrong constant), then make it pass.
+There is no =#mathtest= browser hash — the math is not a UI concern, so it is not
+tested through the UI.
+
+* Phase 2 — perceptual L and APCA readouts
+
+Smallest visible change; validates Phase 1 by eye.
+
+- Extend =pkReadout(hex)= (line 615) to populate new spans for OKLCH L / C / H
+ and APCA Lc, alongside the existing WCAG ratio in the =.pinfo= bar (line 451).
+ Add the spans to the picker DOM (lines 448-451) and minimal CSS.
+- The APCA span carries a compact polarity-aware label (e.g. =APCA Lc -58=); the
+ sign convention (positive = dark-on-light, negative = light-on-dark) is
+ documented in its tooltip and in the README.
+- WCAG remains exactly as-is in the picker and in all three table contrast cells.
+ Per "Agreed decisions" #3, no APCA in the tables for v1.
+
+Pure additions; no behavior changes. Headless guard: =#readouttest= loads a
+known hex and asserts the OKLCH L/C/H and APCA Lc spans carry the expected
+values and the WCAG readout is unchanged.
+
+* Phase 3 — pairwise ΔE across the palette
+
+Self-contained, high value for low-contrast work.
+
+- On =renderPalette()=, compute =deltaE= for every unordered pair of =PALETTE=
+ entries. Flag any pair below the threshold (0.02, the named constant).
+- Warning copy and ordering: sort failing pairs ascending by ΔE (closest first),
+ show the first *5*, and append "and N more" when capped — so a noisy palette
+ never silently hides the count. Format: "blue / steel — ΔE 0.014, hard to
+ distinguish".
+- Each palette chip's =title= gains its nearest-neighbor ΔE.
+- Reuses the chip rendering already in =renderPalette= / =buildPkChips= (line
+ 619). No new rendering surface.
+
+Headless guard: =#deltatest= seeds two near-identical palette colors and asserts
+the warning fires (and names the pair); seeds a well-spread palette and asserts
+it does not; if the cap triggers, asserts the "and N more" suffix and ascending
+order.
+
+* Phase 4 — the OKLCH editor
+
+The largest piece, and the one that delivers "hold lightness while changing
+chroma." Two shippable sub-phases, in order.
+
+** Phase 4a — OKLCH sliders + color-model control
+
+- Add a *separate color-model control* — a segmented =HSV= / =OKLCH= toggle with
+ its *own* state variable =pkModel= — distinct from the existing contrast-mask
+ control (=.pmode= / =pkMode=, values =any= / =aa= / =aaa=). The two are
+ orthogonal concepts: =pkModel= is "how I edit the color," =pkMode= is "what
+ constraint I mask." They must not share state.
+- In OKLCH mode, show L / C / H as numeric + range inputs that drive the color
+ through =oklch2hex=, updating =#newhexstr=, the swatch, and the readouts. On
+ clamp, the sliders snap to the clamped color and the status text appears.
+- No canvas work; delivers the independent-dials benefit immediately.
+
+Headless guard: =#oklchtest= asserts that switching =pkModel= to OKLCH preserves
+the selected color, that toggling the AA/AAA mask does *not* reset =pkModel=, and
+that switching =pkModel= does *not* reset =pkMode=.
+
+** Phase 4b — Chroma×Lightness plane
+
+- When =pkModel= is OKLCH, render the SV box (=#sv=, line 448) as a Chroma (x) by
+ Lightness (y) plane at the current fixed hue; the hue strip is unchanged. The
+ crosshair maps to (C, L) instead of (S, V).
+- *Gamut masking*: high chroma is unreachable at some L/H, so grey out the
+ out-of-gamut region of the plane — reuse the =drawMask()= pattern (line 613),
+ swapping the per-pixel test from "contrast < threshold" to "OKLCH(C,L,H) not
+ in sRGB gamut." The existing AA/AAA contrast mask can overlay on top.
+- *Render cost*: =drawMask()= already samples at =step=4= and runs =contrast()=
+ per cell; the gamut test adds an OKLCH→sRGB conversion per cell, and a naive
+ per-cell binary search on top would be expensive while dragging. Bound it: use
+ a coarse sampling step, cache the rendered plane on a key of
+ (hue + dimensions + mask mode + background hex) so it only recomputes when one
+ changes, and defer the redraw until pointer movement settles. The background
+ hex is in the key because when the AA/AAA contrast overlay is active the mask
+ depends on =MAP['bg']=, so a background edit must invalidate the cached plane.
+ The in-gamut test per cell
+ needs only a forward conversion + channel-range check, not the full binary
+ search (that is reserved for committing a chosen color).
+- This per-pixel gamut render is the only genuinely new rendering logic in the
+ spec, which is why it is sequenced last.
+
+Headless guard: open the picker in OKLCH mode on a known hex via a hash; assert
+the C×L crosshair lands at the expected plane coordinates and that a known
+out-of-gamut coordinate is masked.
+
+* Verification strategy (whole feature)
+
+The test surface is *layered* — a proper pyramid, broad and fast at the bottom,
+thin and DOM-bound at the top:
+
+1. *Unit (Node, the core)* — =test-colormath.mjs= imports =colormath.js= and
+ asserts the math directly under =node --test=. No browser. This is the bulk of
+ the coverage and the place exhaustive testing lives (every conversion, both
+ APCA polarities + chromatic, gamut clamp, ΔE ordering, round-trip property
+ over random hexes). *Coverage is measured here* with Node's built-in reporter
+ (no extra dependency): =node --test --experimental-test-coverage scripts/theme-studio/=.
+ Target for =colormath.js= is ≥90% line/branch (testing.md's utility-code bar);
+ in practice a pure, fully-fixtured module should land at or near 100%, and a
+ gap points at an untested branch worth a case. Coverage of the *core* is a
+ gate; coverage of the browser-executed UI code is out of scope for v1 (it
+ needs CDP/c8 instrumentation and the UI is verified by assertion, not line
+ count).
+2. *UI wiring (browser hash tests)* — only the things that genuinely need a DOM
+ or layout, now that the math is tested below them: =#cursortest= (crosshair
+ pixel position — needs real layout), =#readouttest= (Phase 2, spans populated),
+ =#deltatest= (Phase 3, warning list rendered), =#oklchtest= (Phase 4a,
+ =pkModel= / =pkMode= independence + color preserved across mode switch), the 4b
+ plane test (canvas render + gamut mask). Each appends a =PASS/FAIL= node;
+ command shape:
+ =google-chrome-stable --headless=new --dump-dom 'file://…/theme-studio.html#readouttest'=.
+3. *Integration smoke* — =#selftest= (data roundtrip), re-run every phase to
+ confirm no regression.
+4. *Inline-integrity* — a check (Node or grep) that the generated
+ =theme-studio.html= contains the =colormath.js= source verbatim, so the
+ tested module and the shipped inline copy cannot drift.
+
+Per-phase loop: edit the source (=colormath.js= for math, =generate.py= for the
+page — never hand-edit =theme-studio.html=); =python3 generate.py= to regenerate;
+=node --check= the emitted =<script>=; run the phase's tests (Node unit tests for
+Phase 1, the matching hash test for UI phases); re-run =#selftest= and the
+inline-integrity check; Chrome eyeball for the visible phases (2, 3, 4).
+
+On coverage and why this shape: =generate.py= (~1120 lines) and =samples.py=
+(~269) are the templating/assembly + data layer — string-emission and a sample
+corpus — so Python unit tests there are low value and stay out of scope. The
+logic worth hammering is the color *math*, which is JavaScript; extracting it to
+=colormath.js= makes it directly unit-testable in Node instead of only reachable
+through the rendered app. That is the correction this revision makes: the earlier
+draft tested the math through browser hash tests, which coupled math correctness
+to the DOM and capped coverage at what the UI exercises. With the core extracted,
+the math gets exhaustive direct unit tests and the browser tests shrink to UI
+wiring — the thin-UI-over-tested-core shape an API-first build would have
+produced. The separate =build-theme.el= converter keeps its 22 ERT tests.
+
+* Documentation
+
+Folded into the phases, landing with the code each describes:
+
+- README (=scripts/theme-studio/README.md=): document OKLCH, APCA, and ΔE; the
+ meaning of the signed APCA value; that WCAG remains the compatibility baseline
+ and APCA is an additional perceptual diagnostic, not a replacement.
+- Add the exact commands beside the existing run instructions: the Node unit run
+ with coverage (=node --test --experimental-test-coverage scripts/theme-studio/=)
+ and the headless hash tests (=#readouttest=, =#deltatest=, =#oklchtest=, the 4b
+ plane test).
+
+* Acceptance criteria
+
+- *Phase 1*: =colormath.js= extracted and inlined by =generate.py=;
+ =node --test= green — achromatic, chromatic, and round-trip conversions within
+ epsilon; APCA matches the pinned fixtures (magnitude and sign, both polarities,
+ plus a chromatic fixture); gamut clamp preserves L/H within epsilon, reduces C,
+ returns =clamped= true on out-of-gamut and false unchanged on in-gamut;
+ inline-integrity check confirms the page contains =colormath.js= verbatim;
+ =node --test --experimental-test-coverage= reports =colormath.js= at ≥90%
+ line/branch.
+- *Phase 2*: picker shows OKLCH L/C/H and APCA Lc (with polarity label) next to
+ the WCAG ratio; values match the Node-test references for hand-checked colors;
+ no behavior change to existing flows; tables unchanged; =#selftest= still PASS;
+ =#readouttest= PASS.
+- *Phase 3*: a palette with two near-identical colors raises a visible warning
+ naming the pair and ΔE, sorted closest-first, capped at 5 with "and N more"; a
+ well-spread palette raises none; chip titles carry nearest-neighbor ΔE;
+ =#deltatest= PASS.
+- *Phase 4a*: dragging L changes only lightness (C and H readouts hold); same for
+ C and H independently; =pkModel= and =pkMode= are independent (=#oklchtest=
+ PASS); clamp shows status text.
+- *Phase 4b*: the C×L plane crosshair opens on the current color's (C, L);
+ out-of-gamut regions are masked; the plane render stays responsive while
+ dragging (cached on hue/dims/mask key).
+
+* Implementation phases
+
+One shippable phase per increment, in dependency order, each gated on its own
+headless test plus a clean =#selftest=. These map to the drop-in =todo.org=
+tasks (filed in workflow Phase 6, after Craig confirms Ready):
+
+1. *Math foundation* — extract the color core into =colormath.js= (OKLab/OKLCH,
+ APCA-W3 0.1.9, ΔE-OK, gamut clamp, plus the migrated lin/rl/contrast/hsv
+ helpers); =generate.py= inlines it; =test-colormath.mjs= unit tests + the
+ inline-integrity check; gate =node --test= green.
+2. *Picker readouts* — OKLCH L/C/H + APCA Lc spans beside WCAG; gate
+ =#readouttest= + =#selftest=.
+3. *Palette ΔE warnings* — pairwise ΔE, sorted/capped warning, chip-title
+ nearest-neighbor; gate =#deltatest=.
+4a. *OKLCH sliders + color-model control* — =pkModel= separate from =pkMode=,
+ L/C/H inputs, clamp status; gate =#oklchtest=.
+4b. *Chroma×Lightness plane* — gamut-masked C×L render with caching; gate the 4b
+ plane test.
+
+A test-surface task keeps the Node unit tests, the UI hash tests, the
+inline-integrity check, =#selftest=, the script syntax check, and manual Chrome
+validation green across the feature.
+
+* Review dispositions
+
+Modified or rejected recommendations only; everything else in the Codex review
+(2026-06-08) was accepted as written and woven into the body above.
+
+- *Modified — APCA "transcribe vs vendor" question (high-priority finding 4).*
+ The review asked the spec to "state whether the project is comfortable
+ transcribing the algorithm rather than vendoring a package." Reframed as
+ settled rather than open: the tool is a single self-contained generated HTML
+ file with no build step or package manager, so transcription is the only path
+ consistent with the architecture. The source URL + version pin and the fixture
+ values were accepted in full.
+- *Modified — ΔE-OK default threshold (high-priority finding 1 / open question).*
+ The review accepted ΔE-OK on its native scale "with a named default threshold"
+ but left the number to the author. Concretized to 0.02 (the OKLab
+ just-noticeable-difference floor) as a named, calibratable constant, so the
+ warning fires only on genuinely indistinguishable pairs rather than deferring
+ the choice to implementation.
+
+* Review and iteration history
+
+** 2026-06-08 Mon @ 13:00:34 -0500 — Codex — reviewer
+- *What changed or was recommended:* Created the implementation-readiness review
+ and marked the spec =Not ready=. The review recommends resolving the listed
+ open questions, pinning the APCA source/fixtures, specifying OKLCH gamut clamp
+ semantics, separating color-model mode from contrast-mask mode, and converting
+ the increments into todo-ready implementation phases.
+- *Why:* The feature direction fits the current theme-studio architecture, but
+ coding from this draft would force the implementer to decide product behavior
+ around DeltaE thresholds, APCA placement, OKLCH UI state, and out-of-gamut
+ colors.
+- *Artifacts:* the review file (consumed and deleted on response).
+
+** 2026-06-08 Mon @ 13:08:00 -0500 — Claude Code — responder
+- *What changed:* Processed every Codex recommendation. Accepted all five
+ high-priority findings — resolved the four open questions into "Agreed
+ decisions (v1)", fixed the gamut clamp as binary-search chroma reduction
+ returning a =clamped= flag with picker status text, separated =pkModel=
+ (HSV/OKLCH) from =pkMode= (mask), pinned APCA-W3 0.1.9 with black/white
+ fixtures, and added an "Implementation phases" decomposition (1, 2, 3, 4a, 4b)
+ with per-phase test gates. Accepted the medium findings: chromatic =#mathtest=
+ fixtures, ΔE warning sort/cap copy, C×L render-cost bounds, refreshed
+ background to include the package-face table surface, the named hash tests
+ (=#readouttest=, =#oklchtest=), and the README/doc additions. Two modifications
+ recorded in "Review dispositions": the APCA transcribe-vs-vendor question
+ (settled by the no-build architecture) and the ΔE-OK default threshold (set to
+ 0.02).
+- *Why:* Converge to implementation-ready — remove every product decision from
+ the coding path. Rubric raised Draft → Ready pending Craig's confirm on the
+ four baked-in v1 decisions.
+- *Artifacts:* consumed and deleted =theme-studio-perceptual-color-metrics-spec-review.org=;
+ see "Review dispositions" and "Agreed decisions (v1)". Implementation tasks to
+ be filed in =todo.org= on confirm (workflow Phase 6).
+
+** 2026-06-08 Mon @ 13:15:26 -0500 — Codex — reviewer
+- *What changed or was recommended:* Re-reviewed the updated spec and marked it
+ =Not ready= on one remaining blocker: the APCA section pins =apca-w3= 0.1.9 and
+ says to transcribe constants verbatim, but still shows rounded luminance
+ coefficients instead of the exact published =colorSpace= constants. Also noted
+ two non-blocking edits: include background hex in the 4b mask cache key when
+ contrast masking is active, and either provide the =#67809c= OKLCH fixture
+ values or make that second chromatic fixture optional.
+- *Why:* The implementation is otherwise ready-shaped, but APCA math and numeric
+ fixtures need a single authoritative source before coding starts.
+- *Artifacts:* [[file:theme-studio-perceptual-color-metrics-spec-review.org][theme-studio-perceptual-color-metrics-spec-review.org]]
+
+** 2026-06-08 Mon @ 13:19:15 -0500 — Claude Code — responder
+- *What changed:* Processed Codex's second pass. Accepted all three findings, no
+ modify/reject. Blocking: replaced the rounded APCA luminance coefficients
+ (=0.2126=/=0.7152=/=0.0722=) with the exact APCA-W3 0.1.9 =colorSpace=
+ constants (=0.2126729=/=0.7151522=/=0.0721750=), and stated the remaining APCA
+ constants are transcribed verbatim from the pinned source rather than restated
+ in the spec, so there is one authority. Non-blocking: added =MAP['bg']= to the
+ Phase 4b mask cache key (the AA/AAA overlay depends on background); added
+ concrete OKLCH fixture values for =#67809c= (L 0.591, C 0.052, H 252°); added a
+ chromatic APCA fixture to =#mathtest= so black/white can't hide coefficient
+ drift.
+- *Why:* Remove the last numeric ambiguity so =#mathtest= fixtures and the
+ implementation agree on one exact APCA algorithm. Reviewer confirmed no design
+ gaps remain — only the author-confirm caveat.
+- *Artifacts:* consumed and deleted the second
+ =theme-studio-perceptual-color-metrics-spec-review.org=. Rubric stays =Ready,
+ pending author confirm= on the four v1 decisions; Phase 6 task-filing still
+ holds for that confirm.
+
+** 2026-06-08 Mon @ 13:21:11 -0500 — Codex — reviewer
+- *What changed or was recommended:* Re-read the spec-review workflow and
+ critically reviewed the updated spec against the current theme-studio code,
+ README, prior review dispositions, and task tracking. Assigned =Ready with
+ caveats=: no blocking implementation ambiguity remains, and the prior APCA
+ coefficient issue is resolved by the exact =apca-w3= =colorSpace= constants.
+ The only caveat is the spec's explicit author-confirm step before Phase 1.
+- *Why:* The spec now defines v1 scope, APCA source/fixtures, OKLCH gamut policy,
+ picker state ownership, DeltaE threshold/copy, performance guardrails, docs,
+ acceptance criteria, and shippable phases well enough for implementation.
+- *Artifacts:* No review file written; no blocking findings.
+
+** 2026-06-08 Mon @ 15:21:55 -0500 — Craig Jennings (review) + Claude Code (responder) — final review
+- *What changed:* Craig's final-review questions (six cj comments) answered and
+ the load-bearing ones folded into the body. Factual correction: WCAG 3 is still
+ a Working Draft (final Recommendation ~2028–2030) and APCA was moved *out* of
+ the WCAG 3 draft in 2023 — so the Background no longer frames APCA as a coming
+ standard, only as an independent diagnostic, with WCAG 2.x kept as baseline
+ because nothing has replaced it. Clarifications added to the Agreed decisions:
+ what CIEDE2000 is and why ΔE-OK suffices (#1); that no low-contrast preset
+ exists today and what one would add (#2); that table APCA is low-complexity and
+ the deferral is about density not difficulty (#3); the reason HSV stays default
+ until the 4b plane lands (#4). The Verification section now states Python
+ coverage is ~0% by design (the generator is output-tested via hash tests) and
+ the target is per-behavior hash-test gating, not a line-coverage number.
+- *Why:* Craig interrogated the decisions before confirming; the answers either
+ reinforce them or give him what he needs to override. No decision changed.
+- *Artifacts:* WCAG 3 status via W3C/industry sources (June 2026); repo check
+ confirmed no Python test files / pytest config for =scripts/theme-studio/=.
+
+** 2026-06-08 Mon @ 15:34:36 -0500 — Craig Jennings (direction) + Claude Code (responder) — testing-strategy override
+- *What changed:* Craig overrode the v1 testing approach. The prior draft tested
+ the color math through browser hash tests (=#mathtest=), which coupled math
+ correctness to the DOM and capped coverage at what the UI exercises. Corrected
+ to a layered pyramid: the pure math is extracted into =scripts/theme-studio/colormath.js=
+ (an importable ES module that =generate.py= inlines into the page), unit-tested
+ directly in Node (=test-colormath.mjs=, =node --test=) with exhaustive fixtures
+ + a round-trip property test; the browser hash tests shrink to UI wiring only
+ (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/4b-plane); =#selftest=
+ stays as integration smoke; an inline-integrity check guards the module and the
+ inlined copy against drift. =#mathtest= is removed — the math is no longer a UI
+ concern. Updated Phase 1, Verification, Acceptance, Implementation phases, and
+ Documentation to match. Language correction: the math is JavaScript (emitted by
+ the Python), so the "Python unit tests" instinct lands as Node unit tests on the
+ extracted JS core; the Python stays templating/data and is out of test scope.
+- *Why:* Test the core directly, keep the UI thin — the API-first shape this
+ app grew past. Direct unit tests on the math are faster, more exhaustive, and
+ not limited by the UI surface.
+- *Decisions 1-4 confirmed* as written (4: OKLCH readouts always shown; only the
+ editing model is opt-in until 4b). Phase 6 task-filing + commit still pending
+ Craig's go.
diff --git a/docs/specs/theme-studio-seeding-engine-spec-doing.org b/docs/specs/theme-studio-seeding-engine-spec-doing.org
new file mode 100644
index 000000000..baf9f5b01
--- /dev/null
+++ b/docs/specs/theme-studio-seeding-engine-spec-doing.org
@@ -0,0 +1,354 @@
+:PROPERTIES:
+:ID: b70b37f2-37df-4c8e-ac2f-1f20d12e33dd
+:STATUS: doing
+:END:
+#+TITLE: theme-studio — seeding engine (role table to guide-correct defaults)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-08
+
+* Status
+
+Spec / review incorporated (Codex, 2026-06-08). Turns the color-assignment guide's seed table
+and shade budget into an executable seeding engine: the tool opens with every
+tier (syntax, UI faces, org-mode package faces) already colored to the guide's
+defaults, so the user retunes hues with the picker rather than building a theme
+from blank. Also reseeds the bundled =dupre= theme to the canonical compact
+mapping (it currently diverges on two roles).
+
+Derives directly from =scripts/theme-studio/theme-coloring-guide.org= — the seed
+table (role to palette-family / weight / channel) and the Shade budget (how many
+shades each hue family carries). This spec encodes that table as data, classifies
+each tier's faces into roles, and applies the table to produce the defaults.
+
+Rubric: *Ready.* Craig answered the four open questions (folded into Agreed
+decisions) and Codex's review is incorporated. One decision reshapes the plan: v1
+generates shades with OKLCH (Craig's call), reusing the perceptual-metrics
+=colormath.js= core, so this feature sequences after that spec's Phase 1. Two v1
+phases, each headless-testable.
+
+* Background — how the tool seeds today
+
+=scripts/theme-studio/generate.py= holds three face inventories, each with its
+own ad-hoc default source:
+
+- *Syntax* — =CATS=, 21 categories keyed =bg p kw bi pp fnd fnc dec ty prop con
+ num str esc re doc cm cmd var op punc=. Defaults come from =COLS= (in
+ =samples.py=) into =MAP= and =BOLD=. There is no role layer; each category
+ carries a hand-set color.
+- *UI faces* — =UI_FACES= (20 faces) with defaults in =UIMAP=, hand-authored.
+ This map already follows the guide closely (state faces are background-only,
+ active louder than idle, error/warning/success on the conventional hues), which
+ is the validation that the guide's principles describe a good UI tier rather
+ than invent one.
+- *Package faces* — =APPS[app].faces=, each row =[face, label, default-dict]=.
+ =seedPkgmap()= reads the per-face default-dict. About twenty bespoke packages
+ (org, magit, elfeed, mu4e, ghostel, dashboard, lsp-mode, flycheck, dired,
+ dirvish, calibredb, erc, signel, pearl, slack, telega, shr, and more) carry
+ curated seed colors; generic inventory packages (from =package-inventory.json=)
+ seed to the default foreground.
+
+Three problems this spec addresses:
+
+1. *No role layer.* Each tier's defaults are set face-by-face by hand. There is
+ no single place that says "definitions are the warm anchor, bold" and projects
+ it onto syntax, UI, and org at once. The guide now states that table; the tool
+ does not consume it.
+2. *dupre diverges from its own guide.* The compact mapping says builtins are
+ blue-grey and function definitions are gold; =dupre= assigns builtins to blue
+ (=bi= shares =kw='s hue) and definitions to silver (=fnd=). The guide records
+ this as a known divergence to be reseeded.
+3. *Tiers do not open guide-correct.* UI is close by luck of hand-tuning; syntax
+ carries dupre's divergence; org's long tail is unseeded. Opening seeded across
+ all three is the goal.
+
+* Goal
+
+A seeding engine with three parts and one surfacing rule:
+
+1. *The seed model as data* — a named palette with the shade budget, a
+ role-to-treatment table, and a face-to-role map per tier. The guide's table,
+ made executable.
+2. *A =seed()= operation* — applies the role table through each tier's
+ face-to-role map to produce the default assignments (=MAP=/=BOLD= for syntax,
+ =UIMAP= for UI, =PKGMAP= defaults for packages).
+3. *Reseed dupre* — regenerate =dupre-revised.json= from the engine so it matches
+ the compact mapping (builtins blue-grey, definitions gold).
+
+Surfacing rule (Craig): the tool *opens seeded*. The syntax tier is already
+guide-correct on load, so the user adjusts hues with the picker, then scrolls to
+the UI faces. A "reseed from guide" button restores the defaults on demand.
+
+Non-goals: role-mapping the non-org bespoke packages (org is the one document
+package worth a role map; the other ~20 keep their existing curated =APPS= seeds,
+and reseed resets them to those defaults rather than flattening them — see
+Package scope); per-tier reseed controls (v1 reseeds all three owned tiers at
+once).
+
+* The seed model
+
+** Palette and shade budget
+
+A named swatch set, one to three shades per hue family, per the guide's Shade
+budget. The names are the contract. v1 *generates* the shades with OKLCH (Craig's
+call): each family is anchored by a base hue (the dupre anchors — blue, gold,
+regal, sage, terracotta), and its quieter or brighter shades are derived by
+stepping OKLCH lightness/chroma from that anchor, using the perceptual-metrics
+=colormath.js= core. Generation is a first guess; any hue that reads wrong gets a
+hand-authored override swatch. Rough shape:
+
+- *Neutrals:* =ground= (bg), =bg-dim=, =fg=, =muted-fg=, =comment=.
+- *Blue:* =blue= (keyword), =blue-grey= (builtin — blue at lower chroma/lightness).
+- *Gold:* =gold= (definition), =gold-quiet= (call).
+- *Violet:* =regal= (types/decorators).
+- *Green:* =sage= (string), =sage-muted= (docstring), =sage-bright= (escape).
+- *Teal:* =teal= (regexp).
+- *Terracotta:* =terracotta= (numbers/constants).
+- *Signal:* =red=, =amber=, =green=, =blue= (reused) for error/warning/success/link.
+
+Roughly fifteen swatches across seven or eight hues. The builtin =blue-grey= and
+the call =gold-quiet= are the swatches dupre is missing today and gains on
+reseed.
+
+** Role-to-treatment table
+
+The guide's seed table as data: each role maps to a swatch, a weight, an optional
+slant/underline, and a channel (foreground or background). One literal object,
+e.g.
+
+#+begin_src js
+ROLES = {
+ base: {swatch:'fg', weight:'normal', channel:'fg'},
+ structure: {swatch:'muted-fg', weight:'normal', channel:'fg'},
+ control: {swatch:'blue', weight:'bold', channel:'fg'},
+ builtin: {swatch:'blue-grey', weight:'normal', channel:'fg'},
+ def: {swatch:'gold', weight:'bold', channel:'fg'},
+ call: {swatch:'gold-quiet', weight:'normal', channel:'fg'},
+ type: {swatch:'regal', weight:'normal', channel:'fg'},
+ string: {swatch:'sage', weight:'normal', channel:'fg'},
+ docstring: {swatch:'sage-muted', slant:'italic', channel:'fg'},
+ escape: {swatch:'sage-bright',weight:'normal', channel:'fg'},
+ literal: {swatch:'terracotta', weight:'normal', channel:'fg'},
+ comment: {swatch:'comment', slant:'italic', channel:'fg'},
+ state: {swatch:'tint', channel:'bg'},
+ sig_error: {swatch:'red', channel:'fg'},
+ sig_warn: {swatch:'amber', channel:'fg'},
+ sig_ok: {swatch:'green', channel:'fg'},
+ sig_link: {swatch:'blue', underline:true, channel:'fg'},
+ heading: {swatch:'ramp', channel:'fg'}, // see heading ramp
+}
+#+end_src
+
+** Face-to-role maps
+
+*** Syntax (CATS key to role)
+
+=p=, =var= to base; =op=, =punc=, =cmd= to structure; =kw= to control; =pp= to
+control (shared, optionally muted); =bi= to builtin; =fnd= to def; =fnc= to call;
+=dec=, =ty=, =prop= to type; =con=, =num= to literal; =str= to string; =doc= to
+docstring; =esc=, =re= to escape (=re= to a teal variant if present); =cm= to
+comment; =cmd= to structure (delimiter, dimmer). =bg= is the ground, set
+directly.
+
+*** UI faces (UI_FACES to role)
+
+=region=, =hl-line=, =highlight=, =show-paren-match= to state (background tint,
+no fg); =isearch= to an active match chip (may invert); =lazy-highlight= to a
+quieter match; =isearch-fail=, =show-paren-mismatch= to sig_error; =error= to
+sig_error, =warning= to sig_warn, =success= to sig_ok; =link= to sig_link;
+=mode-line= to active chrome, =mode-line-inactive=, =line-number=, =fringe=,
+=vertical-border= to idle/receding chrome; =line-number-current-line= to active
+chrome; =cursor= to its own; =minibuffer-prompt= to control.
+
+*** Org-mode (face to one of six roles)
+
+=org-level-1..8= to heading ramp; =org-meta-line=, =org-drawer=,
+=org-special-keyword=, =org-property-value=, =org-block-begin-line= /
+=org-block-end-line=, =org-ellipsis=, =org-tag=, =org-date=,
+=org-document-info-keyword= to markup-recede; =org-block=, =org-code=,
+=org-verbatim=, =org-inline-src-block= to code-like (reuse the syntax literal
+lane); =org-todo= / imminent deadlines to sig (warm), =org-upcoming-deadline= to
+sig_warn, =org-scheduled= / =org-done= to receded/cool (with =org-done= taking
+strikethrough); =org-link= to sig_link; =org-quote=, =org-verse= to emphasis
+(italic). The org long tail that does not classify seeds to base, as today.
+
+** Package scope
+
+The role engine owns three default sources: syntax, UI, and the *org-mode*
+package faces. It does not touch the other ~20 bespoke packages in =APPS= (magit,
+elfeed, mu4e, and the rest): their curated seed colors stay exactly as today, and
+the reseed button *resets them to their existing =APPS= defaults* rather than
+role-generating or flattening them to foreground. Generic inventory packages keep
+seeding empty/default. So =seed(model)= returns =packages.org-mode= only; the
+non-org defaults continue to flow from =seedPkgmap()= over the curated =APPS=
+dicts, and reseed re-runs =seedPkgmap()= for them. A =#seedtest= asserts a non-org
+bespoke package (e.g. magit) keeps its curated seed after open and after reseed.
+
+Reseeding preserves the package-face import guarantees already established by
+=mergePackagesInto= / =packagesForExport= (unknown app/face preservation, old-JSON
+compatibility, recoverable references to deleted palette colors); this spec does
+not re-decide them.
+
+** Heading ramp
+
+=org-level-1..8= share one hue across three or four lightness steps (the guide
+does not spend eight distinct shades). v1 generates the steps with OKLCH: from a
+base hue, step lightness down per level (level 1 strongest and bold, deeper levels
+quieter), cycling the steps past level 4. This uses the same =colormath.js= shade
+generation as the palette above.
+
+* The seed() operation
+
+A pure function, =seed(model)= returns ={syntax, ui, packages}= default
+assignments:
+
+- *syntax*: for each =CATS= key, look up its role, resolve the role's swatch to a
+ hex and its weight, produce =MAP[key]= and =BOLD[key]=.
+- *ui*: for each =UI_FACES= face, resolve its role to =UIMAP[face]= ({fg, bg,
+ bold, italic, underline}), honoring the channel (state roles set bg only).
+- *packages.org-mode*: for each org face, resolve its role to a default-dict
+ ({fg, bg, bold, italic, strike, inherit, height}).
+
+The output is exactly the shape =exportObj()= already emits (=assignments=,
+=ui=, =packages=), so =seed()= produces a =theme.json= the existing import path
+loads unchanged. =packages= carries only =org-mode= (Package scope); the non-org
+curated defaults flow through =seedPkgmap()= as today. Reseeding dupre is
+=seed(model)= combined with the curated package seeds, written to
+=dupre-revised.json= (the canonical package-aware artifact — see Surfacing).
+
+* Surfacing in the tool
+
+- *Open seeded.* The page's initial =MAP=/=UIMAP= come from =seed(model)= (inlined
+ defaults), not from hand-set =COLS=/=UIMAP=; =PKGMAP= comes from =seed(model)='s
+ org defaults plus =seedPkgmap()= over the curated =APPS= dicts for the rest. On
+ load the syntax tier is guide-correct; the user retunes hues and scrolls to UI.
+- *Reseed button.* A "reseed from guide" control reapplies the seeds to all three
+ owned tiers and resets the non-org packages to their curated =APPS= defaults. It
+ warns first, naming the scope: "Reseed syntax, UI, and package defaults from the
+ guide? This discards current color assignments."
+- *Canonical artifact.* The reseeded bundle is written to =dupre-revised.json=,
+ the full package-aware file the README and =build-theme.el= example use.
+ =dupre.json= stays a legacy minimal import fixture (no =packages= key) unless
+ deliberately migrated. Importing the reseeded =dupre-revised.json= and opening
+ fresh land on the same state.
+
+* Implementation phases
+
+1. *Seed model + seed() + tests.* Add the palette anchors + OKLCH shade
+ generation (reusing =colormath.js=), the =ROLES= table, and the three
+ face-to-role maps as data in =generate.py= (or a sibling inlined like
+ =samples.py=); write the pure =seed()=. Gate: =#seedtest= asserts representative
+ faces land on the right swatch/weight/channel in each tier (=bi= blue-grey,
+ =fnd= gold + bold, =var= base, =op= / =punc= muted, =doc= italic; =region= /
+ =hl-line= bg-only, =link= underlined, =error= / =warning= / =success= on signal
+ hues, active vs inactive chrome differentiated; =org-level-1= strongest,
+ =org-code= the fixed-pitch literal lane, =org-done= receded/struck) AND that a
+ non-org bespoke package (e.g. magit) keeps its curated seed.
+2. *Open seeded + reseed + dupre-revised regen.* Wire the initial state to
+ =seed(model)= (plus =seedPkgmap()= for the non-org packages); add the all-tier
+ reseed button with the scope-named overwrite warning, resetting non-org
+ packages to their =APPS= defaults; regenerate =dupre-revised.json= from the
+ engine. Gate: =#selftest= still PASS; a headless check that default-on-open
+ equals =seed(model)=; an *artifact round-trip* check that the regenerated
+ =dupre-revised.json= imports back to the same seeded state (package defaults and
+ source markers included); a Chrome eyeball that the seeded syntax tier reads as
+ a coherent dupre.
+
+Dependency: v1 reuses the perceptual-metrics =colormath.js= core for OKLCH shade
+generation, so it sequences after that spec's Phase 1 (the math foundation). No
+second color-math implementation.
+
+* vNext candidates
+
+- Per-tier reseed controls (reseed just syntax, just UI, just org) after the
+ all-at-once v1 button.
+- Role-mapping selected non-org bespoke packages beyond org, if their curated
+ defaults prove worth regenerating from the table.
+- The guide-support views and advisories already tracked in =todo.org=.
+
+* Acceptance criteria
+
+- *Phase 1*: =seed()= is pure and table-driven; representative faces in all three
+ tiers resolve to the guide's seed-table treatment; a non-org bespoke package
+ keeps its curated seed; OKLCH generation produces the family shades and the
+ heading ramp; =#seedtest= PASS.
+- *Phase 2*: the tool opens with syntax/UI/org seeded from =seed(model)= and the
+ non-org packages on their curated =APPS= defaults; the reseed button restores
+ all three owned tiers (and resets non-org to curated defaults) behind a
+ scope-named warning; =dupre-revised.json= is regenerated, matches the compact
+ mapping (=bi= blue-grey, =fnd= gold), and round-trips back to =seed(model)= on
+ import; =#selftest= PASS; a Chrome eyeball confirms a coherent dupre.
+
+* Agreed decisions (v1)
+
+Answered by Craig (2026-06-08), folded in.
+
+1. *Palette swatch source.* Generate the shades with OKLCH and fix hues that read
+ wrong by hand override (Craig overrode the hand-authored recommendation). This
+ moves OKLCH generation into v1 and makes the feature reuse the
+ perceptual-metrics =colormath.js= core, sequencing after that spec's Phase 1.
+2. *Heading ramp depth.* Three or four distinct lightness steps, cycled across
+ levels 1-8.
+3. *Converter sharing.* Tool-only for v1; =build-theme.el= consumes the exported
+ =theme.json= regardless.
+4. *Reseed scope.* All three owned tiers at once; per-tier reseed is vNext.
+
+* Review dispositions
+
+Codex's review (2026-06-08) was accepted in full. The items below note the two
+findings that corrected factual errors in the draft and the one open choice this
+response resolved; everything else was woven into the body as written.
+
+- *Corrected — package scope (high-priority finding 2).* The draft said non-org
+ packages "seed to the default foreground." Wrong: =APPS= carries curated seeds
+ for ~20 bespoke packages. Rewritten so the role engine owns only org among
+ packages and the rest keep their curated =APPS= defaults, with reseed resetting
+ to those (see Package scope).
+- *Corrected — canonical artifact (high-priority finding 3).* The draft named
+ =dupre.json=; the package-aware bundle is =dupre-revised.json=. Replaced
+ throughout, with =dupre.json= noted as the legacy minimal fixture.
+- *Resolved — OKLCH dependency (high-priority finding 1).* The review offered two
+ routes to OKLCH-in-v1 (depend on the perceptual-metrics core, or build a local
+ minimal helper). Chose the dependency, to avoid a second color-math
+ implementation.
+
+* Sources
+
+- =scripts/theme-studio/theme-coloring-guide.org= — the seed table and Shade
+ budget this engine executes.
+- =scripts/theme-studio/generate.py= — =CATS=, =UI_FACES=/=UIMAP=, =APPS= /
+ =seedPkgmap=, =exportObj= (the target shape).
+- =docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org= — the
+ =colormath.js= core that v1 OKLCH shade generation uses.
+
+* Review and iteration history
+
+** 2026-06-08 Mon @ 19:00:17 -0500 — Codex — reviewer
+- *What changed or was recommended:* Created the implementation-readiness review
+ and marked the spec =Not ready=. The review treats the inline =cj: comment=
+ blocks as answered product input, then flags three remaining implementation
+ blockers: the palette-source answer moves OKLCH generation into v1 while the
+ phase plan still says vNext; the package seeding scope conflicts with the
+ current many-bespoke-app =APPS= surface; and the spec names =dupre.json= even
+ though the current README/build path uses =dupre-revised.json= as the full
+ package-aware artifact.
+- *Why:* The role-table direction is sound, but those conflicts would force the
+ implementer to decide dependency order, package-default preservation, and
+ which bundled JSON file to write.
+- *Artifacts:* the review file (consumed and deleted on response; see Review dispositions).
+
+** 2026-06-08 Mon @ 19:06:06 -0500 — Claude Code — responder
+- *What changed:* Folded Craig's four answers into Agreed decisions and accepted every Codex finding. OKLCH shade generation moved into v1 (reusing =colormath.js=, sequenced after perceptual-metrics Phase 1); package scope rewritten so the engine owns only org and the ~20 non-org bespoke packages keep their curated =APPS= seeds (reseed resets to those); =dupre.json= references replaced with =dupre-revised.json= (canonical, package-aware) and =dupre.json= noted legacy; phases collapsed to two v1 phases with a vNext section; added the non-org-preserved and artifact-round-trip tests, the scope-named reseed warning, and the import-guarantee citation.
+- *Why:* The draft described an older, narrower package surface and the wrong dupre artifact; left uncorrected, an implementer could wipe curated package defaults or write the wrong file. Two of the three blockers fixed errors in my draft. Rubric Draft to Ready.
+- *Artifacts:* consumed and deleted the review file; see Review dispositions and Agreed decisions.
+
+** 2026-06-08 Mon @ 19:11:06 -0500 — Codex — reviewer
+- *What changed or was recommended:* Re-reviewed the incorporated spec against
+ the current generator, README, task tracking, and previous findings. Assigned
+ =Ready=: the OKLCH dependency, non-org package seed preservation, and
+ =dupre-revised.json= artifact story are now explicit. Fixed one stale
+ non-blocking source note that still referred to Phase 3.
+- *Why:* The spec now gives an implementer a coherent v1: two phases, explicit
+ dependency on perceptual-metrics Phase 1, table-driven =seed()=, open-seeded
+ and reseed behavior, package preservation rules, artifact round-trip tests,
+ and vNext boundaries.
+- *Artifacts:* No review file written; no blocking findings.
diff --git a/docs/specs/theme-studio-structured-output-spec.org b/docs/specs/theme-studio-structured-output-spec.org
new file mode 100644
index 000000000..ad189b7eb
--- /dev/null
+++ b/docs/specs/theme-studio-structured-output-spec.org
@@ -0,0 +1,157 @@
+:PROPERTIES:
+:ID: eaac7707-ed05-43df-9e51-b17c1d672531
+:STATUS: not-started
+:END:
+#+TITLE: Theme-Studio Structured Theme Output — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-15
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | not-started |
+|----------+----------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: theme-studio output + dupre retirement]] |
+|----------+----------------------------------------------------------------|
+
+* Summary
+
+Replace build-theme.el's flat deftheme (literal hex on every face) with a structured two-file output: a palette file naming each color, and a theme file whose face assignments reference the palette through a binding. A hue change becomes one edit that propagates to every face on that color, the output reads meaningfully, and the same assignments paired with a different palette make a variant. The hand-authored dupre theme is retired in the same effort: it survives only as the fallback and a structural reference now that a theme-studio export (WIP) is the active theme.
+
+* Problem / Context
+
+build-theme.el converts a theme-studio theme.json into a deftheme, and it does so flat: one =custom-theme-set-faces= with a literal hex per face and no color layer above it (the converter's own header says "Do not hand-edit; re-run the converter"). It is faithful but unreadable, and a single hue change touches every face that used that color, scattered across the file. The structure that made the hand-authored dupre theme maintainable — a palette of named colors, faces referencing those names, organized by category — is discarded at generation time.
+
+dupre carried that structure in a three-file split (theme / palette / faces), and that structure was the value. But it is theme-specific source the user no longer wants to hand-maintain. WIP, a theme-studio export, is already the active theme (=persist/emacs-theme= reads "WIP"); dupre is now only =fallback-theme-name= and a reference. theme.json already carries a named palette — a list of =[hex name family]= triples — so the data needed to generate a structured theme already exists; only the converter throws it away.
+
+The driver: make generated themes inherit dupre's structural virtues in a generated-appropriate shape, and remove dupre.
+
+* Goals and Non-Goals
+
+** Goals
+- build-theme emits two files: =NAME-palette.el= (named colors) and =NAME-theme.el= (deftheme entry plus assignments that reference palette names).
+- A hue change is one edit in theme.json's palette, re-exported, and reaches every face on that color.
+- Assignments are separable from palette, so a palette swap yields a variant theme.
+- Faces are grouped and commented by tier (default, syntax, ui, packages) for readability.
+- Output stays one-way generated (do-not-hand-edit banner); theme.json is canonical.
+- dupre is removed: the three theme files and its test deleted, =fallback-theme-name= moved to a built-in, references and comments updated.
+
+** Non-Goals
+- No semantic-role layer (accent/err/keyword → palette) in v1 — deferred, but the format leaves room for it.
+- No OKLCH ramps, perceptual palette renaming, or auto light/dark variants.
+- No change to theme-studio's editing UI.
+- Not changing the theme-studio model: the palette already exists in theme.json; v1 reads it, it does not redesign it.
+
+** Scope tiers
+- v1: build-theme two-file structured output; the palette file from theme.json's palette list; face assignments referencing palette names via a binding, one-off hexes left literal; tier organization; regenerate the active theme in the new format; retire dupre and move the fallback.
+- vNext: a semantic-role layer; per-face palette-name carriage in theme-studio (preserve intent when two roles share a hex); palette-swap variant tooling.
+
+* Design
+
+** For the user
+Tune in theme-studio and export, as today. The generated theme is now two files. The palette file lists every named color once. The theme file maps each face to a color by name, grouped by area (syntax, UI, packages) so it reads like a description of the theme rather than a hex dump. To shift a hue, change it in theme-studio and re-export; every face on that color moves together. The same theme file paired with a different palette file is a variant — the lineage that took distinguished to dupre, made explicit.
+
+** For the implementer
+build-theme/--render splits into two emitters fed by the parsed theme.json:
+- Palette emitter: from theme.json's =palette= list of =[hex name family]=, write =NAME-palette.el= — a =defconst NAME-palette= (or a set of named constants) mapping name to hex, optionally grouped by family with section comments, ending in =(provide 'NAME-palette)=.
+- Theme emitter: write =NAME-theme.el= — the =deftheme=, =(require 'NAME-palette)=, then =custom-theme-set-faces= wrapped in a binding over the palette names (a =let= built from the palette, mirroring dupre-with-colors) so face specs reference names. Each face's stored hex is reverse-mapped to a palette name by exact match; a hex absent from the palette stays a literal string. Faces grouped by tier with comments. End =(provide-theme 'NAME)=.
+Both files carry the generated/do-not-hand-edit banner. =NAME-theme.el= requires =NAME-palette.el=, so the themes directory must be on the load path at theme-load time (the existing dupre arrangement already does this for the themes dir).
+
+* Alternatives Considered
+
+** Keep the flat per-face-hex output
+- Good: no converter change; the output is trivially correct.
+- Bad: unreadable, and a hue change is scattered across every face — the maintainability problem this spec exists to fix.
+- Neutral: it is generated, so "unreadable" matters only when a human reads or hand-tweaks it, which the structured format is meant to enable.
+
+** Three-file split (theme / palette / faces), exactly like dupre
+- Good: maximal separation; the deftheme boilerplate is isolated.
+- Bad: a generated theme's deftheme wrapper is a few lines — a third file is more ceremony than generated output needs.
+- Neutral: could become warranted in vNext if the assignments file grows unwieldy.
+
+** Carry a palette-name reference per face in theme.json (no reverse-map)
+- Good: preserves the designer's intended name even when two roles share a hex.
+- Bad: a theme-studio model and export change, larger than v1; the reverse-map gets the same readable output from data that already exists.
+- Neutral: the better long-term design; logged as vNext.
+
+* Decisions [6/6]
+
+** DONE Two-file output shape
+- Context: an Emacs theme needs =NAME-theme.el= for discovery; the palette wants to be independently swappable.
+- Decision: Two files — =NAME-palette.el= (named colors) and =NAME-theme.el= (deftheme entry plus assignments). The assignments ride with the deftheme rather than getting a third file.
+- Consequences: easier — palette swaps for variants, one place to retune hues, less ceremony than dupre's three files; harder — the theme file still mixes deftheme boilerplate with assignments.
+
+** DONE Faces reference the palette via a binding
+- Context: faces must name colors, not inline hex, for the one-edit-propagates property.
+- Decision: The theme file wraps =custom-theme-set-faces= in a =let= over the palette names (mirroring dupre-with-colors) and face specs reference the names.
+- Consequences: easier — readable specs, single source of color truth; harder — the converter must build the binding and reverse-map face hexes to names.
+
+** DONE Derive the palette layer by reverse-mapping face hex to palette names
+- Context: theme.json stores resolved hex per face but already carries a named palette list (=[hex name family]=).
+- Decision: build-theme reads the palette list to emit the palette file and reverse-maps each face's hex to a palette name by exact match; a hex with no palette entry stays a literal string.
+- Consequences: easier — no theme-studio model change, uses data that already exists; harder — a hex shared by two intended roles collapses to one name (intent loss), which per-face name carriage would fix in vNext.
+
+** DONE Semantic-role layer deferred
+- Context: dupre had roles (accent/err/keyword → palette) above the palette; the user may want them later.
+- Decision: No role layer in v1. The format keeps the palette binding so a role binding can slot above it later without reshaping the output.
+- Consequences: easier — smaller v1, fewer indirection layers to reason about; harder — role intent is not captured yet, so a role rename is a vNext addition.
+
+** DONE Retire dupre, move the fallback to a built-in
+- Context: WIP (a theme-studio export) is already active; dupre is only =fallback-theme-name= and a reference; the fallback has no further fallback, so it must be guaranteed present.
+- Decision: Delete the three dupre files and =test-dupre-theme.el=; set =fallback-theme-name= to "modus-vivendi" (built-in, always available); update the persistence/commands tests and the stale comments in auto-dim-config.el and org-config.el.
+- Consequences: easier — removes hand-maintained theme source, retires the four already-failing dupre palette tests; harder — the fallback loses chosen dimming colors (acceptable for a rare last resort), and dupre's look survives only in git and in WIP's lineage.
+
+** DONE Generated files stay one-way; theme.json is canonical
+- Context: the current converter already declares its output do-not-hand-edit.
+- Decision: Both generated files keep the generated banner; hue changes and palette swaps happen in theme-studio (or by generating from another theme.json), not by editing the output.
+- Consequences: easier — no source-of-truth ambiguity, regeneration is always safe; harder — a quick hand-tweak to the palette file is overwritten on the next export, so experiments route through theme-studio.
+
+* Implementation phases
+
+** Phase 1 — palette emitter
+Emit =NAME-palette.el= from theme.json's palette list: name→hex constants (grouped by family with comments), =(provide 'NAME-palette)=, generated banner. Done when the palette file loads and exposes every named color.
+
+** Phase 2 — theme emitter with palette references
+Rewrite build-theme/--render to emit =NAME-theme.el=: deftheme, require the palette, =custom-theme-set-faces= inside a =let= over the palette, face specs referencing names (reverse-mapped from hex; literals for one-offs), tier grouping and comments, =provide-theme=. Done when a theme.json round-trips to a loading theme whose faces render identically to the old flat output. Update test-build-theme.el to the two-file shape.
+
+** Phase 3 — regenerate the active theme
+Regenerate WIP (the active theme) in the new format via deploy-wip; confirm it loads and looks unchanged in the live daemon. Done when the round-trip lands with no visible difference.
+
+** Phase 4 — retire dupre
+Set =fallback-theme-name= to "modus-vivendi"; update test-ui-theme-commands.el and test-ui-theme-persistence.el; fix the stale comments in auto-dim-config.el and org-config.el; delete themes/dupre-theme.el, dupre-palette.el, dupre-faces.el and tests/test-dupre-theme.el. Done when the suite is green, startup uses WIP, and the fallback resolves to modus-vivendi. (Independent of Phases 1-3 — can land first since WIP is already active in the old format.)
+
+* Acceptance criteria
+- [ ] build-theme produces =NAME-palette.el= and =NAME-theme.el= for a given theme.json.
+- [ ] The generated theme loads and its faces render identically to the prior flat output for the same theme.json.
+- [ ] Changing one palette color in theme.json and re-exporting updates every face that used it.
+- [ ] The palette file names every distinct palette color; one-off face hexes remain literal.
+- [ ] dupre's files and test are gone; startup uses WIP; =fallback-theme-name= resolves to a present theme; suite green.
+
+* Readiness dimensions
+- Data model & ownership: theme.json (theme-studio) is canonical; the palette list is the color source; build-theme owns the generated files; both are one-way output.
+- Errors, empty states & failure: a face hex absent from the palette falls back to a literal — no failure, just an unnamed color. A missing palette file fails the theme load loudly (require error) rather than silently mis-coloring.
+- Security & privacy: N/A — color data only.
+- Observability: the live theme and theme-studio preview are the visible surface; a wrong reverse-map shows as a wrong color.
+- Performance & scale: N/A — tens of colors, ~150 faces, generated once per export.
+- Reuse & lost opportunities: rides the existing palette list and build-theme tiers; sets up palette-swap variants and a future role layer.
+- Architecture fit & weak points: mirrors dupre's proven palette/faces separation. Weak point is the hex→name reverse-map collapsing shared hexes — bounded by leaving one-offs literal and deferring name carriage to vNext.
+- Config surface: =fallback-theme-name= changes value; the themes load-path must include the generated palette file.
+- Documentation plan: the generated banner plus this spec; no user-facing docs.
+- Dev tooling: existing =make theme-studio-theme=, =deploy-wip=, and the build-theme test suite cover build and round-trip.
+- Rollout, compatibility & rollback: Phase 4 (dupre removal) is independent and reversible via git; Phases 1-3 change only generated output, rollback is reverting build-theme. The active theme (WIP) keeps working in the old format until regenerated.
+- External APIs & deps: =deftheme=, =custom-theme-set-faces=, =provide-theme=, =custom-theme-load-path= — all standard; modus-vivendi is built in.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Reverse-map ambiguity: one hex, several intended roles, collapses to one name. Dodge: leave one-offs literal; defer per-face name carriage to vNext.
+- Identical render is the bar: the structured output must produce the same face attributes as the flat output. Dodge: a converter test that diffs resolved face specs old-vs-new for a fixture theme.json.
+- Load-path for the palette file: =NAME-theme.el= requiring =NAME-palette.el= needs the themes dir on the path at load time. Dodge: reuse dupre's existing arrangement.
+- Scope creep into the role layer or OKLCH work. Dodge: both are explicit Non-Goals / vNext.
+
+* Review and iteration history
+** 2026-06-15 Mon — Craig — author
+- What: initial draft.
+- Why: the dupre retirement turned into a question of what shape theme-studio's generated themes should take; the palette-vs-flat format, the file split, and the reverse-map approach are real trade-offs worth settling before touching build-theme.el.
+- Artifacts: scripts/theme-studio/build-theme.el (current flat renderer), scripts/theme-studio/theme.json (palette list already present), themes/dupre-* (the structural reference being retired).
diff --git a/docs/specs/utility-consolidation-spec-doing.org b/docs/specs/utility-consolidation-spec-doing.org
new file mode 100644
index 000000000..b0a5fe2bd
--- /dev/null
+++ b/docs/specs/utility-consolidation-spec-doing.org
@@ -0,0 +1,1220 @@
+:PROPERTIES:
+:ID: fc2e3926-b4a1-4b45-92eb-20841e13f655
+:STATUS: doing
+:END:
+#+TITLE: Design: Consolidate Shared Utility Helpers
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-04
+
+* Status
+
+Draft. Specification only. No helper extraction is part of this document.
+
+This is the sibling project to [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][Untangle the init.el Load Graph]]. The load-graph
+project decides when modules load and what dependencies they declare. This
+project decides which module should own reusable helper behavior.
+
+* Framing Questions
+
+Before extracting helpers, ask these questions for each candidate:
+
+1. Is this behavior duplicated, or merely similar?
+2. Does the proposed helper have at least two real consumers?
+3. Can the helper live in a foundation library without pulling heavy packages
+ into startup?
+4. Is the helper pure or mostly pure, or does it create files, processes,
+ timers, buffers, warnings, messages, or network traffic?
+5. Does it need to be available to production code, tests only, or both?
+6. Will extraction make the caller easier to read, or will it hide important
+ domain decisions behind a vague utility name?
+7. Can the tests move with the helper while preserving consumer behavior tests?
+8. Is an existing Emacs primitive already good enough?
+9. Is this a library function, a command helper, or a module-specific private
+ detail?
+10. What compatibility story is needed for existing public =cj/= commands?
+
+The default answer should be "do not extract yet" unless the helper has clear
+reuse pressure and low dependency cost. The exception is a helper that currently
+lives in a clearly wrong dependency layer and blocks the load-graph work; those
+speculative extractions are allowed only when this spec names the expected
+future consumers and the migration keeps the old caller behavior covered.
+
+* Candidate Decision Criteria
+
+Use intent first and implementation shape second. A helper is a real extraction
+candidate when multiple callers are enforcing the same policy, even if the
+current functions were written differently.
+
+Do not reject a candidate just because the implementations differ. Differences
+may be accidental drift, local naming, or missing parameters. Also do not accept
+a candidate just because the implementations look similar. Similar code can
+still represent different workflow policies.
+
+Treat a candidate as real when most of these are true:
+
+- Same job: one domain-neutral sentence describes what both callers are trying
+ to do.
+- Same failure semantics: callers agree on whether failure should return nil,
+ warn, signal =user-error=, skip silently, or fall back.
+- Same side-effect policy: callers agree on whether the helper may message,
+ warn, write files, create buffers, start processes, or mutate state.
+- Same dependency layer: the helper can live somewhere both callers can
+ reasonably require without pulling heavy package/domain dependencies into
+ foundation startup.
+- Differences are parameters, not hidden modes: warning type, feature name,
+ current directory, TTL, or trim behavior are reasonable parameters; broad
+ flags that make one helper behave like several unrelated helpers are a smell.
+- Tests can describe the policy without loading unrelated domain modules.
+- Call sites get clearer because the shared policy has a good name and the
+ caller still owns workflow-specific consequences.
+
+When callers share only part of the intention, extract the shared policy core
+and leave workflow decisions local. For example, executable lookup can be shared
+while mail, programming, and media modules still decide whether a missing tool
+means "disable sync," "skip a hook," or "show an interactive warning."
+
+* Problem
+
+Several modules define helper functions where they were first needed. Some of
+those helpers are truly private. Others are general utilities that now have
+multiple consumers or obvious near-duplicates. This creates architectural drag:
+
+- A feature module becomes the accidental owner of generic behavior.
+- Other modules either duplicate the behavior or depend on a feature module for
+ a helper they should not conceptually require.
+- Tests are harder to place because helper logic is mixed with package config,
+ keybinding setup, timers, external processes, and user commands.
+- The =init.el= load graph stays harder to untangle because helper ownership is
+ not explicit.
+
+The goal is not to build a large personal standard library. The goal is to
+extract a small set of proven helpers into predictable, dependency-light
+libraries with focused tests.
+
+A prior review estimated roughly 221 private helpers across 31 modules. That
+count is useful motivation for an inventory, but it is not the extraction
+target. This project should pull only the helpers whose ownership and reuse
+case are clear.
+
+* Goals
+
+- Identify concrete helper functions that should be moved, renamed, wrapped, or
+ deliberately left alone.
+- Keep foundation helpers dependency-light and safe to load early.
+- Give each helper family a clear home and naming convention.
+- Preserve existing behavior at call sites.
+- Move unit tests with extracted helper behavior.
+- Keep migrations small: one helper family per commit.
+- Improve direct module loading by replacing hidden cross-module assumptions
+ with explicit =require= statements.
+
+* Non-Goals
+
+- Renaming every =cj/= function.
+- Turning command modules into libraries when their behavior is user-facing.
+- Extracting helpers with only one consumer unless they are already in the wrong
+ dependency layer.
+- Replacing useful built-in APIs such as =file-in-directory-p= with wrappers
+ that add no policy.
+- Moving heavy package-specific behavior into =system-lib=.
+- Combining this work with lazy-loading changes in the same commit.
+
+* Existing Library Shape
+
+A file with user-facing interactive commands is a feature/command module, not a
+shared library. Mixed files can keep private helper functions, but those helpers
+should move to =system-lib.el= or a topic library only when reuse pressure and
+the candidate criteria justify extraction.
+
+** =system-lib.el=
+
+Current role: low-level system utility library. It is already expected to be a
+foundation module.
+
+Current functions:
+
+- =cj/executable-exists-p=
+- =cj/log-silently=
+
+Recommended role: foundation helpers that are dependency-light, batch-safe, and
+reasonable to load early.
+
+Good fits:
+
+- executable lookup,
+- shell argument formatting,
+- process execution wrappers built on =process-file=,
+- warning/message convenience wrappers,
+- simple file/path predicates,
+- simple file string read/write helpers.
+
+Bad fits:
+
+- helpers that require Org, mu4e, projectile, dirvish, vc-git, url, gptel, or
+ other package/domain dependencies,
+- helpers that start asynchronous processes/timers at load time,
+- helpers whose semantics are really workflow-specific.
+
+Dependency budget:
+
+- Allowed without additional design: built-ins already available at startup,
+ =subr-x=, =cl-lib=, and =seq=.
+- Allowed only with an explicit note in the helper's section:
+ =host-environment=, because it participates in early foundation/load-order
+ decisions.
+- Not allowed in =system-lib.el=: Org, VC internals such as =vc-git=, Dired
+ implementation packages beyond declarations, url/network libraries, mu4e,
+ projectile, dirvish, gptel, media packages, or any package that would make
+ optional workflows part of foundation startup.
+
+Any helper needing dependencies outside this budget belongs in a topic library
+or the domain module that already owns that dependency.
+
+** =system-utils.el=
+
+Current role: user-facing system commands and Emacs enhancements. It mixes
+commands, package config, keybindings, external open behavior, savehist,
+scratch-buffer setup, dictionary, and proced.
+
+Recommended role: command/config module, not a low-level library.
+
+Potential extraction from here:
+
+- =cj/--file-from-context= should move to =system-lib= as a generic path helper.
+- =cj/--open-with-is-launcher-p= should move if external-open behavior is shared.
+- =cj/identify-external-open-command= should move to =external-open.el= as
+ workflow-owned command resolution.
+
+After extraction, =system-utils.el= should require the library and keep the
+interactive commands.
+
+** =config-utilities.el=
+
+Current role: interactive maintenance/debug commands for this Emacs config.
+
+Recommended role: keep as command/config module. Do not turn it into a generic
+library.
+
+Potential extraction:
+
+- =with-timer= should become =cj/with-timer= only if other modules need the
+ macro.
+- =cj/--delete-compiled-files-in-dir= may become a generic recursive file
+ deletion helper only if another production caller needs the same behavior and
+ the destructive policy is explicit.
+- build-info formatting helpers should stay here; they are command-specific.
+
+** =testutil-general.el=
+
+Current role: test-only filesystem helpers.
+
+Recommended role: keep test harness helpers here unless a production module
+needs the same safety policy. Do not make production code depend on
+=testutil-general=.
+
+Potential production extraction:
+
+- =cj/test--assert-inside-base= -> =cj/path-assert-in-directory=
+- =cj/test--safe-base-dir-p= -> =cj/safe-recursive-delete-root-p=
+
+These should move only when a production destructive workflow needs them.
+
+* Library File Header Standard
+
+Shared library files should document their own scope in the commentary header.
+The design spec records the rationale; the file header is the contributor-facing
+contract that should be visible during ordinary edits.
+
+Required header content:
+
+1. =;; Role:= foundation utility library or topic library role.
+2. =;; Layer:= matching the load-graph architecture.
+3. =;; Dependency budget:= allowed, note-required, and forbidden dependencies.
+4. =;; What belongs here:= concrete helper families.
+5. =;; What does not belong here:= workflow/domain behavior and heavy
+ dependencies.
+6. =;; Adding a helper:= two-consumer rule plus wrong-layer speculative
+ exception.
+7. =;; Naming:= public helper naming policy.
+8. =;; Tests:= expected test-file convention.
+9. =;; Renaming:= obsolete alias policy.
+10. =;; See also:= design docs for rationale.
+
+Worked =system-lib.el= header:
+
+#+begin_src emacs-lisp
+;;; system-lib.el --- Foundation utility helpers -*- lexical-binding: t; -*-
+;;
+;;; Commentary:
+;;
+;; Role: Foundation utility library (Layer 1).
+;;
+;; This file owns reusable, dependency-light helpers used across feature
+;; modules. It loads early in startup and must stay batch-safe.
+;;
+;; Dependency budget:
+;; Allowed without note: built-ins, subr-x, cl-lib, seq.
+;; Allowed with note: host-environment (foundation peer).
+;; Not allowed: Org, vc-git internals, Dired implementation,
+;; url/network libraries, mu4e, projectile,
+;; dirvish, gptel, media packages, or any package
+;; that would attach optional workflows to
+;; foundation startup.
+;;
+;; What belongs here:
+;; - executable lookup (silent predicate or warn-and-return)
+;; - shell argument formatting (readable-when-safe quoting)
+;; - process execution wrappers built on `process-file'
+;; - simple file/path predicates and context resolution
+;; - logging convenience wrappers
+;;
+;; What does not belong here:
+;; - workflow-specific behavior (calendar parsing, Org-roam slug generation,
+;; gptel adapters)
+;; - timers, network requests, or buffer mutations at load time
+;; - helpers that pull a heavy package into foundation startup
+;;
+;; Adding a helper:
+;; Default to "do not extract yet." Extract when at least two callers share
+;; the same job, failure semantics, side-effect policy, and dependency layer.
+;; Speculative extractions are allowed only when a feature module is the
+;; wrong long-term owner; record expected future consumers in
+;; docs/design/utility-inventory.org.
+;;
+;; Naming:
+;; Public helpers use cj/<noun>-<verb> or cj/<domain>-<verb>. Names should
+;; describe policy, not just shape. Do not retain source-module prefixes
+;; after extraction.
+;;
+;; Tests:
+;; tests/test-system-lib-<helper-name>.el, one file per helper. Stub
+;; side-effecting primitives at their boundaries via cl-letf.
+;;
+;; Renaming:
+;; Public helpers in user muscle memory get a one-cycle obsolete alias.
+;; Private helpers rename without alias when all call sites change in the
+;; same commit.
+;;
+;; See also: docs/specs/utility-consolidation-spec-doing.org for design rationale.
+;;
+;;; Code:
+#+end_src
+
+Topic libraries such as =cj-process.el=, =cj-org-text.el=, or =cj-cache.el=
+should follow the same shape with a narrower role and dependency budget.
+
+* Proposed Library Layout
+
+Start with single-file growth in =system-lib.el=. Split later only when the file
+becomes too broad or a helper family needs a dependency that should not be
+foundation-eager.
+
+Recommended phases:
+
+1. Grow =system-lib.el= for the first dependency-light helpers.
+2. Keep Org-specific helpers out of =system-lib= unless they can be written with
+ only strings and =subr-x=.
+3. Introduce topic libraries only when there is a clear reason:
+ - =cj-process.el= for process runners if the process API grows beyond a
+ couple functions.
+ - =cj-org-text.el= for Org-safe text helpers if they start requiring Org
+ APIs.
+ - =cj-cache.el= for cache helpers because that abstraction is stateful and
+ distinct from simple system helpers.
+4. Preserve =system-lib.el= as the easy entry point for the low-level set.
+
+Load shape:
+
+- Each topic library declares its load-graph layer in its file header.
+- =cj-process.el= and =cj-org-text.el= are Layer 1 only if their first consumer
+ is foundation-eager; otherwise they are Layer 2 and loaded by explicit
+ =require= from eager consumers.
+- =cj-cache.el= follows the first real cache consumer's layer, likely Layer 2 if
+ modeline/agenda/refile remain eager or near-eager.
+- Coordinate every new topic library with
+ [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec-doing.org]] before migrating its first consumer.
+
+* Naming Rules
+
+- Library files use =cj-<topic>.el=. The legacy =system-lib.el= name stays for
+ compatibility and serves as the foundation entry point.
+- Public reusable helpers use =cj/<noun>-<verb>= or =cj/<domain>-<verb>=.
+- Private module helpers keep =cj/<module>--<helper>= or
+ =<module>--<helper>=.
+- Do not keep source-module names after extraction. For example,
+ =cj/mail--executable-or-warn= should not become
+ =cj/system-mail-executable-or-warn=.
+- Prefer names that describe policy:
+ - =cj/executable-find-or-warn= is better than =cj/check-program=.
+ - =cj/shell-quote-argument-readable= is better than =cj/shell-quote= because
+ it documents the readable-when-safe policy.
+- Preserve public interactive command names unless there is a separate
+ user-facing rename task.
+- Add obsolete aliases only for functions used outside their defining module or
+ in user muscle memory. Private helpers can be renamed without aliases when
+ all call sites change in the same commit.
+
+* Candidate Extraction Table
+
+This table is intentionally concrete. "Action" describes the recommended end
+state, not necessarily the first commit.
+
+| Current symbol | Current file | Proposed symbol | Proposed home | Action | Priority | Notes |
+|----------------+--------------+-----------------+---------------+--------+----------+-------|
+| =cj/mail--executable-or-warn= | =mail-config.el= | =cj/executable-find-or-warn= | =system-lib.el= | Extract | High | Generalizes missing executable warning; callers include mail, language tools, media/dirvish commands. |
+| =cj/executable-exists-p= | =system-lib.el= | =cj/executable-available-p= | =system-lib.el= | Rename, alias preserved | Medium | New predicate returns boolean. Keep one-cycle obsolete alias/wrapper because the current name is misleading and returns a path. |
+| direct =(executable-find ...)= with silent nil | =prog-c.el=, =prog-go.el=, =prog-python.el=, =prog-shell.el=, =dirvish-config.el=, =browser-config.el=, =mail-config.el= | =cj/executable-find-or-warn= or =cj/executable-available-p= | =system-lib.el= | Migrate selectively | High | Use warnings for user-invoked missing features; keep silent predicates for package =:if= checks when silence is intentional. |
+| =cj/--f6-shell-safe-argument-regexp= | =dev-fkeys.el= | =cj/shell-safe-argument-regexp= | =system-lib.el= | Extract | High | Keep as implementation detail for readable shell quoting. |
+| =cj/--f6-shell-quote-argument= | =dev-fkeys.el= | =cj/shell-quote-argument-readable= | =system-lib.el= | Extract | High | Useful for generated compile/test strings where safe paths should remain readable. |
+| direct =shell-quote-argument= in command strings | =prog-c.el=, =prog-python.el=, =prog-shell.el=, =mail-config.el=, =dirvish-config.el=, =vc-config.el=, =elfeed-config.el=, =system-utils.el= | case-by-case | =system-lib.el= | Audit | Medium | Do not blindly replace; direct quoting is correct when readability is irrelevant or command strings are security-sensitive. |
+| =cj/--coverage-git-string= | =coverage-core.el= | =cj/process-output-or-error= | =system-lib.el= or =cj-process.el= | Extract generic core | High | Generic process-file wrapper: program + argv -> stdout or user-error with status/output. |
+| =cj/--coverage-git-string= | =coverage-core.el= | =cj/git-output-or-error= | =system-lib.el= or =cj-process.el= | Add wrapper | High | Thin wrapper around generic runner with program ="git"=. |
+| =cj/--coverage-git-merge-base= | =coverage-core.el= | keep =cj/--coverage-git-merge-base= | =coverage-core.el= | Keep | Low | Coverage-specific semantics; may call =cj/git-output-or-error=. |
+| =cj/--coverage-git-diff= | =coverage-core.el= | keep =cj/--coverage-git-diff= | =coverage-core.el= | Keep | Low | Coverage-specific =--unified=0= policy. |
+| =cj/--file-from-context= | =system-utils.el= | =cj/file-from-context= | =system-lib.el= | Extract | High | Useful for Dired/current-buffer command helpers. Requires only dired declarations and built-ins. |
+| =cj/--open-with-is-launcher-p= | =system-utils.el= | =cj/external-open-launcher-p= | =external-open.el= | Extract after consumers align | Medium | External-open policy, not core path logic. |
+| =cj/identify-external-open-command= | =system-utils.el= | =cj/external-open-command= | =external-open.el= | Move/rename | Medium | External-open owns command-string resolution; host-environment remains predicate-only. |
+| duplicated OS-open command selection | =system-utils.el=, =dirvish-config.el=, =external-open.el= | =cj/external-open-command= | =external-open.el= | Consolidate | Medium | One source of truth for =xdg-open=, =open=, =start=. |
+| =cj/test--file-in-directory-p= | =test-runner.el= | =file-in-directory-p= (built-in) | built-in | Replace caller with built-in | Medium | Do not create a wrapper unless a real normalization policy emerges. |
+| =cj/test--assert-inside-base= | =testutil-general.el= | =cj/path-assert-in-directory= | =system-lib.el= | Extract only with production caller | Medium | Useful for destructive commands, but currently test-only. |
+| =cj/test--safe-base-dir-p= | =testutil-general.el= | =cj/safe-recursive-delete-root-p= | =system-lib.el= | Extract only with production caller | Medium | Policy-heavy. Should be explicit and well-tested before production use. |
+| =calendar-sync--sanitize-org-body= | =calendar-sync.el= | =cj/org-sanitize-body-text= | =cj-org-text.el= or =system-lib.el= | Extract | High | Already tested; likely useful for webclipper, AI conversations, mail capture. |
+| =calendar-sync--sanitize-org-property-value= | =calendar-sync.el= | =cj/org-sanitize-property-value= | =cj-org-text.el= or =system-lib.el= | Extract | High | String-only behavior; no Org dependency required. |
+| =calendar-sync--sanitize-org-heading= | =calendar-sync.el= | =cj/org-sanitize-heading= | =cj-org-text.el= or =system-lib.el= | Extract | High | Protects outline structure from external text. |
+| =calendar-sync--strip-html= | =calendar-sync.el= | =cj/text-strip-html= | =system-lib.el= or =cj-text.el= | Consider | Medium | Useful beyond calendar, but HTML stripping via regex is intentionally simple and should be documented as such. |
+| =calendar-sync--clean-text= | =calendar-sync.el= | =cj/text-clean-external= | =system-lib.el= or =cj-text.el= | Consider | Medium | Combines ICS unescape + HTML strip today, so it may be too calendar-specific unless split. |
+| =calendar-sync--unescape-ics-text= | =calendar-sync.el= | keep =calendar-sync--unescape-ics-text= | =calendar-sync.el= | Keep | Low | ICS-specific; not a general utility. |
+| =cj/modeline-vc-cache-*= helpers | =modeline-config.el= | =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | =cj-cache.el= | Extract later | Medium | Good pattern, but variable-local cache shape differs from Org caches. Needs design before extraction. |
+| agenda/refile cache vars and build flags | =org-agenda-config.el=, =org-refile-config.el= | =cj/cache-value-or-rebuild= or =cj/build-cache= | =cj-cache.el= | Extract later | Medium | Similar TTL/building/invalidation lifecycle. Higher risk than simple helpers. |
+| =cj/log-silently= | =system-lib.el= | =cj/message-log-only= | =system-lib.el= | Rename, alias preserved | Low | Clearer name for discoverability, but low value. Do after higher-priority helpers unless touched nearby. |
+| direct =display-warning= boilerplate | =mail-config.el= and future callers | =cj/display-warning-once= / =cj/warn-once= | =system-lib.el= | Add after second caller | Low | Do not add until repeated formatting or once-only behavior appears. |
+| =with-timer= | =config-utilities.el= | =cj/with-timer= | =system-lib.el= or stay | Defer | Low | Macro is useful, but currently debug-oriented. Extract only after another production caller appears. |
+| =cj/theme-read-file-contents= | =ui-theme.el= | =cj/read-file-string= | =system-lib.el= | Consider | Low | Built-in =insert-file-contents= wrappers are small; extract only if multiple callers emerge. |
+| =cj/theme-write-file-contents= | =ui-theme.el= | =cj/write-file-string= | =system-lib.el= | Consider | Low | Same as above. Keep theme-specific unless reused. |
+| =cj/modeline-string-cut-middle= | =modeline-config.el= | =cj/string-truncate-middle= | =system-lib.el= or =cj-text.el= | Defer | Low | Only one current production caller. Good candidate if completion, headings, or report buffers need it. |
+| =cj/--benchmark-method= | =config-utilities.el= | keep | =config-utilities.el= | Keep | Low | Debug command helper, not general architecture. |
+| =cj/--delete-compiled-files-in-dir= | =config-utilities.el= | =cj/delete-files-recursively-matching= | maybe =system-lib.el= | Defer | Low | Destructive behavior needs a strong second caller and path safety contract. |
+
+* Recommended Helper Groups
+
+** Group 1: Executable Discovery
+
+Home: =system-lib.el=.
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(defun cj/executable-available-p (program)
+ "Return non-nil when PROGRAM resolves to an executable in PATH.")
+
+(defun cj/executable-find-or-warn (program feature &optional warning-type)
+ "Return PROGRAM's executable path, or warn that FEATURE is unavailable.")
+#+end_src
+
+Migration:
+
+- Rename =cj/executable-exists-p= to =cj/executable-available-p= and keep a
+ one-cycle obsolete alias/wrapper for compatibility.
+- Replace =cj/mail--executable-or-warn= first because it is already the exact
+ behavior.
+- Audit language modules:
+ - Keep existing =use-package :if= checks on built-in =executable-find= during
+ this project unless there is a separate load-order reason to change them.
+ - Use =cj/executable-find-or-warn= for interactive commands where the user
+ asked for a feature and needs a clear explanation.
+- Do not warn during startup for every optional language tool unless the feature
+ is explicitly configured to be active.
+
+Behavior:
+
+- =program= is a non-empty string naming an executable.
+- =cj/executable-available-p= returns =t= or =nil=, never the executable path.
+- =cj/executable-find-or-warn= returns the resolved executable path or =nil=.
+- =feature= is a human-readable string used in the warning message.
+- =warning-type= defaults to ='system-lib= unless the caller passes a more
+ specific module symbol.
+- Missing executables warn with level =:warning=.
+- Invalid =program= values (non-string or empty string) signal
+ =wrong-type-argument= via =cl-check-type=. This is a programmer-error path;
+ user-facing error reporting is the caller's responsibility.
+
+Tests:
+
+- program string validation,
+- successful lookup returns path,
+- missing program returns nil,
+- warning type defaults sensibly,
+- warning message includes program and feature.
+
+** Group 2: Shell Command String Helpers
+
+Home: start in =system-lib.el=.
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(defconst cj/shell-safe-argument-regexp "\\`[[:alnum:]_./=+@%:,^-]+\\'")
+
+(defun cj/shell-quote-argument-readable (argument)
+ "Return ARGUMENT unchanged when safe, otherwise shell-quote it.")
+#+end_src
+
+Policy:
+
+- Use this helper only when building shell command strings for display,
+ compilation, or logging and readable safe paths matter.
+- Use plain =shell-quote-argument= when maximum conservatism is preferred and
+ readability does not matter.
+- Prefer argv/process APIs over shell strings for new process execution when
+ possible.
+
+First consumers:
+
+- =dev-fkeys.el= F6 command builder.
+- Candidate later consumers: =prog-c.el= compile command generation,
+ =prog-python.el= test/debug commands, =prog-shell.el= shellcheck command,
+ =mail-config.el= mbsync command.
+
+Justification:
+
+- This is a speculative extraction with one current concrete consumer. It is
+ allowed because =dev-fkeys.el= is not the right long-term owner for shell
+ argument policy, the helper is dependency-free, and several command-building
+ modules already make the same readability/security tradeoff manually.
+
+Behavior:
+
+- =argument= must be a string.
+- Arguments matching =cj/shell-safe-argument-regexp= are returned unchanged.
+- Other arguments are passed to =shell-quote-argument=.
+- This helper is for shell command strings only. New process execution helpers
+ should accept argv lists and should not use this helper internally.
+
+Tests:
+
+- safe paths unchanged,
+- whitespace quoted,
+- shell metacharacters quoted,
+- nil/non-string behavior explicitly errors or normalizes.
+
+** Group 3: Process Execution
+
+Home: =system-lib.el= initially, split to =cj-process.el= if the API grows.
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(cl-defun cj/process-output-or-error
+ (program args &key cwd stdin error-message trim)
+ "Run PROGRAM with ARGS via `process-file' and return stdout.")
+
+(defun cj/git-output-or-error (&rest args)
+ "Run git with ARGS and return stdout, or signal `user-error'.")
+#+end_src
+
+Minimum behavior:
+
+- Accept argv as a list, not a shell command string.
+- Capture stdout/stderr together unless a caller needs them separated.
+- Include program, args, exit status, and trimmed output in failure errors.
+- Optionally bind =default-directory= to =cwd=.
+- Return raw stdout by default; allow =:trim t= for callers that need it.
+
+First migration:
+
+- Extract the generic logic from =cj/--coverage-git-string=.
+- Keep =cj/--coverage-git-merge-base= and =cj/--coverage-git-diff= in
+ =coverage-core.el= because their semantics are coverage-specific.
+
+Justification:
+
+- This is a speculative extraction with one current concrete consumer. It is
+ allowed because =coverage-core.el= is not the right long-term owner for a
+ generic argv/process error-reporting policy, and the helper is a prerequisite
+ for later shell-command hardening in VC, repo reconciliation, Hugo, and
+ language command modules.
+
+Behavior:
+
+- =program= is a non-empty string.
+- =args= is a list of strings.
+- The helper uses =process-file=, not a shell.
+- =cwd=, when non-nil, temporarily binds =default-directory=.
+- =stdin= is out of scope for the first implementation and must be nil. Add a
+ separate design note before supporting string/buffer/file stdin.
+- Stdout and stderr are captured together in a temporary buffer unless a later
+ caller proves separated streams are needed.
+- Exit status =0= is success even when stderr text exists.
+- Exit status non-zero signals =user-error=.
+- Exit status =0= with empty stdout returns =""=, not =nil=.
+- =trim= nil returns raw output. =:trim t= uses =string-trim-right= so leading
+ output whitespace remains intact while common trailing newlines are removed.
+- =error-message= is an optional caller label prepended to the generated error;
+ it does not replace command/status/output details.
+
+Likely later consumers:
+
+- hardened =vc-config.el= clone command,
+- =reconcile-open-repos.el= repository scans,
+- =hugo-config.el= deploy/build commands,
+- language compile/test helpers where argv execution is practical.
+
+Tests:
+
+- success returns stdout,
+- non-zero status signals =user-error=,
+- error includes argv/status/output,
+- cwd is honored,
+- empty output behavior is defined,
+- no shell interpolation occurs.
+
+Table grouping:
+
+- The =cj/process-output-or-error= and =cj/git-output-or-error= rows are one
+ extraction commit. The git helper should be a thin wrapper over the generic
+ process helper.
+
+** Group 4: File/Path Context And Safety
+
+Home: =system-lib.el= for simple predicates; keep test-only setup helpers in
+=testutil-general.el=.
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(defun cj/file-from-context (&optional explicit-filename)
+ "Return a file path from explicit input, current buffer, or Dired point.")
+
+(defun cj/path-assert-in-directory (path directory)
+ "Signal an error unless PATH is inside DIRECTORY.")
+
+(defun cj/safe-recursive-delete-root-p (dir)
+ "Return non-nil when DIR is specific enough to delete recursively.")
+#+end_src
+
+Policy:
+
+- Prefer built-in =file-in-directory-p= directly unless the caller needs a named
+ project policy.
+- Extract =cj/file-from-context= early because it is a useful command helper and
+ already lives in a too-broad command module.
+- Extract deletion safety only when a production destructive command is ready to
+ consume it. The test harness can continue using test-local names until then.
+
+First consumers:
+
+- =system-utils.el= =cj/open-file-with-command= and =cj/xdg-open=.
+- =external-open.el= and =dirvish-config.el= once their file-open behavior is
+ aligned.
+- Production destructive/deploy commands only after policy review.
+
+Behavior:
+
+- =cj/file-from-context= resolves in priority order: explicit filename,
+ current =buffer-file-name=, Dired file at point.
+- It returns =nil= rather than prompting. Interactive commands decide whether
+ to prompt or signal.
+- It may declare Dired functions but must not require a Dired implementation
+ package at load time.
+- =cj/path-assert-in-directory= signals =user-error= with both paths in the
+ message.
+- =cj/safe-recursive-delete-root-p= is not implemented until a production
+ destructive caller is ready to adopt it.
+
+Tests:
+
+- explicit filename wins,
+- buffer file fallback,
+- Dired point fallback,
+- nil when no context exists,
+- path containment handles =..= and symlinks according to documented policy,
+- destructive safe-root rejects =/=, =~/=, =temporary-file-directory=,
+ =user-emacs-directory=, and =default-directory=.
+
+** Group 5: External Open Command Resolution
+
+Home: =external-open.el=.
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(defun cj/external-open-command ()
+ "Return the platform default opener command.")
+
+(defun cj/external-open-launcher-p (command)
+ "Return non-nil when COMMAND should be detached as a desktop launcher.")
+#+end_src
+
+Decision:
+
+- =external-open.el= owns platform opener command resolution because this is
+ workflow policy, not a foundation predicate.
+- =external-open.el= may require =host-environment= for predicates.
+- =host-environment.el= remains predicate-only.
+- =system-lib.el= does not learn external-open workflow semantics.
+- Have =dirvish-config.el= call that owner rather than duplicating OS cases.
+
+Behavior:
+
+- =cj/external-open-command= returns a command string or signals =user-error=
+ for unsupported hosts.
+- The Linux default is =xdg-open=, macOS is =open=, and Windows is =start=.
+ A future helper for explicitly opening folders in Explorer is out of scope
+ for this group.
+- =cj/external-open-launcher-p= returns boolean and has no side effects.
+- The helpers only resolve commands; callers remain responsible for choosing
+ =call-process=, =start-process=, or a shell fallback.
+
+Tests:
+
+- Linux returns =xdg-open=,
+- macOS returns =open=,
+- Windows returns =start=,
+- unsupported host errors clearly,
+- launcher predicate handles =xdg-open=, =open=, =start=.
+
+** Group 6: Org-Safe Text
+
+Home: =cj-org-text.el= from first extraction.
+
+This is also the first topic library to land; the extraction commit
+demonstrates the topic-library header pattern from [[*Library File Header Standard][Library File Header
+Standard]].
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(defun cj/org-sanitize-body-text (text)
+ "Prevent external body TEXT from creating unintended Org headings.")
+
+(defun cj/org-sanitize-property-value (text)
+ "Flatten TEXT for safe use as an Org property value.")
+
+(defun cj/org-sanitize-heading (text)
+ "Flatten TEXT for safe use as an Org heading.")
+#+end_src
+
+Source:
+
+- =calendar-sync--sanitize-org-body=
+- =calendar-sync--sanitize-org-property-value=
+- =calendar-sync--sanitize-org-heading=
+
+Likely consumers:
+
+- =calendar-sync.el= event headings/properties/body,
+- =org-webclipper.el= clipped page titles/content,
+- =ai-conversations.el= model output persisted into Org,
+- =mail-config.el= mail subjects inserted into capture/templates,
+- future external ingest workflows.
+
+Justification:
+
+- This is a speculative extraction with one current concrete consumer family.
+ It is allowed because =calendar-sync.el= is not the right long-term owner for
+ generic external-text Org safety, the helpers are string-only, and several
+ external-ingest workflows need the same policy.
+
+Important distinction:
+
+- =calendar-sync--unescape-ics-text= should stay in =calendar-sync.el= because
+ it is ICS-specific.
+- =calendar-sync--strip-html= may become =cj/text-strip-html= later, but only
+ with a docstring that says it is a lightweight regex cleanup, not a full HTML
+ parser.
+
+Behavior:
+
+- Nil input returns nil.
+- Body text preserves line breaks but replaces leading Org heading stars with
+ dashes so external text cannot create outline entries.
+- Property values and headings flatten newlines and collapse internal
+ whitespace to a single space.
+- Heading sanitization composes body sanitization and property-value
+ flattening.
+- These helpers do not parse Org. They are string guards for external text
+ before insertion into Org structures.
+
+Tests:
+
+- Move the existing calendar sanitizer tests to the new helper names.
+- Add consumer tests showing calendar output still sanitizes correctly.
+- Add webclipper/AI/mail tests only when those modules are migrated.
+
+** Group 7: Cache With TTL And Invalidation
+
+Home: =cj-cache.el=, not =system-lib.el=, once implemented.
+
+Current patterns:
+
+- =modeline-config.el=: per-buffer VC cache with key/time/value/set-p and
+ after-save/after-revert invalidation.
+- =org-agenda-config.el=: global agenda-file cache with TTL and building flag.
+- =org-refile-config.el=: global refile-target cache with TTL and building
+ flag.
+
+Proposed API shape:
+
+#+begin_src emacs-lisp
+(cl-defstruct cj/cache
+ key value timestamp set-p ttl building-p)
+
+(defun cj/cache-valid-p (cache key &optional now)
+ "Return non-nil when CACHE has a value for KEY that has not expired.")
+
+(defun cj/cache-get-or-rebuild (cache key rebuild-fn &optional force)
+ "Return CACHE value for KEY or rebuild it with REBUILD-FN.")
+
+(defun cj/cache-clear (cache)
+ "Clear CACHE state.")
+#+end_src
+
+This API is only illustrative. The real design must decide whether caches are:
+
+- structs stored in one variable,
+- plists,
+- closures,
+- several caller-owned variables with helper predicates.
+
+Recommendation:
+
+- Do not extract cache helpers first.
+- Behavioral normalization (lifecycle alignment between agenda/refile caches)
+ is owned by init-load-graph Phase 6. This project extracts the shared pattern
+ into =cj-cache.el= once that lifecycle alignment lands, or in parallel if the
+ design addendum proves the API can drive the alignment.
+- Then decide whether modeline's buffer-local cache can use the same library or
+ should remain specialized.
+- Phase 5 step 1 produces =docs/specs/cache-helper-design-spec-implemented.org=. Until that
+ file exists, =cj-cache.el= must not be created. The addendum is the
+ prerequisite for any cache extraction commit.
+
+Tests:
+
+- valid cache hit,
+- forced rebuild,
+- TTL expiration,
+- nil value can be cached distinctly from "not set",
+- rebuild flag is cleared on errors,
+- hook invalidation clears only intended cache scope.
+
+** Group 8: Logging And Warnings
+
+Home: =system-lib.el=.
+
+Proposed API:
+
+#+begin_src emacs-lisp
+(defun cj/message-log-only (format-string &rest args)
+ "Append a formatted message to *Messages* without minibuffer echo.")
+
+(cl-defun cj/display-warning-once (type message &key level key)
+ "Display warning MESSAGE once for KEY.")
+#+end_src
+
+Recommendation:
+
+- Rename =cj/log-silently= to =cj/message-log-only= with an obsolete alias when
+ this low-priority helper is touched. Do not put this rename in the first
+ extraction wave.
+- Do not add =cj/display-warning-once= until there is repeated once-only warning
+ behavior. For ordinary warnings, =display-warning= is already readable.
+
+Behavior:
+
+- =cj/message-log-only= preserves the current =cj/log-silently= behavior:
+ append one formatted message to =*Messages*= without echoing in the
+ minibuffer, ensure the message starts on its own line, and ensure the buffer
+ ends with a newline.
+- =cj/display-warning-once= is not implemented until a second caller proves the
+ need. When implemented, duplicate suppression is process-local and keyed by
+ =(type key)=. =:level= defaults to =:warning=.
+
+Tests:
+
+- log-only inserts into =*Messages*=,
+- warning-once suppresses duplicates by key,
+- warning-once does not suppress unrelated warnings.
+
+** Group 9: File Content Helpers
+
+Home: =system-lib.el= only if reused.
+
+Potential API:
+
+#+begin_src emacs-lisp
+(defun cj/read-file-string (file)
+ "Return FILE contents as a string.")
+
+(defun cj/write-file-string (file contents)
+ "Write CONTENTS to FILE, creating parents if requested by option.")
+#+end_src
+
+Source:
+
+- =cj/theme-read-file-contents=
+- =cj/theme-write-file-contents=
+
+Recommendation:
+
+- Defer. The theme helpers are small and theme-specific today.
+- Extract only when another module needs identical read/write behavior.
+
+* Functions To Leave Alone For Now
+
+- =calendar-sync--parse-*=, =calendar-sync--expand-*=, and
+ =calendar-sync--unescape-ics-text=: calendar/ICS domain logic.
+- =cj/--coverage-parse-simplecov= and =cj/--coverage-parse-diff-output=:
+ coverage domain parsing.
+- =cj/--coverage-git-merge-base= and =cj/--coverage-git-diff=: coverage-specific
+ git policy after the low-level runner is extracted.
+- =cj/modeline-string-cut-middle=: good utility shape, but currently only one
+ real caller.
+- =cj/--generate-roam-slug= and org-roam formatting helpers: Org-roam workflow
+ semantics. These stay local because they are data/workflow-aware, unlike the
+ string-only Org sanitizers that only protect external text from becoming
+ unintended headings or malformed properties.
+- text editing commands in =custom-*.el=: many are reusable commands, but they
+ are user-facing editing features rather than foundation utilities.
+- package configuration helpers in =prog-*.el=: keep mode/package-specific
+ setup close to the package unless a generic policy emerges.
+- test fixture builders in individual test files: keep local unless three or
+ more suites duplicate the same fixture shape.
+
+* Migration Phases
+
+Extraction commits should use conventional commit prefixes consistently:
+
+- =refactor:= for behavior-preserving helper moves/renames and call-site
+ migrations.
+- =feat:= only when adding a new reusable helper for a new user-visible
+ capability.
+- =test:= for test-only follow-up work.
+- =docs:= for spec, inventory, and design addendum updates.
+
+** Phase 1: Inventory And Tags
+
+Create an inventory of private helpers across =modules/=.
+
+Inventory artifact:
+
+- Create =docs/design/utility-inventory.org=.
+- Use an Org table with the fields below.
+- Scope it to the candidate table in this spec plus new candidates discovered
+ during module walkthroughs. It is not required to list every private helper
+ across the whole codebase before Phase 2 can start.
+- Treat the inventory as living documentation. Cleared high-priority candidates
+ may move to Phase 2 before the whole inventory is complete.
+- This inventory is independent from the module-shape inventory maintained by
+ [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec-doing.org]]. The two projects may walk the same files, but they
+ record different facts in separate artifacts.
+
+For each helper record:
+
+- current symbol,
+- file,
+- public/private status,
+- dependencies,
+- side effects,
+- candidate home,
+- proposed name,
+- real consumers,
+- test file(s),
+- extraction priority,
+- decision: extract, defer, keep, or replace with built-in.
+
+Audit output:
+
+- An =Audit= row produces an inventory decision: =Migrate=, =Leave=, or
+ =Defer=.
+- =Migrate= decisions should create or update a concrete =todo.org= task.
+- =Leave= and =Defer= decisions should record the rationale in the inventory so
+ the same audit is not repeated later.
+
+Exit criteria:
+
+- Every candidate in the table above is represented.
+- Each candidate has at least one specific next action.
+- No code behavior changes.
+
+** Phase 2: Low-Risk Foundation Helpers
+
+Extract helpers that are string/path/process-light and either have direct
+consumers or are explicitly justified as wrong-layer speculative extractions in
+this spec.
+
+Suggested order:
+
+1. =cj/executable-find-or-warn=
+2. =cj/shell-quote-argument-readable=
+3. =cj/process-output-or-error= and =cj/git-output-or-error=
+4. =cj/file-from-context=
+
+Exit criteria:
+
+- Each extraction has moved or added focused tests.
+- Consumer modules explicitly require =system-lib=.
+- Full targeted tests pass after each extraction.
+- Startup does not gain new package dependencies.
+- Existing =use-package :if= checks are not migrated away from built-in
+ predicates unless a separate load-order task explicitly requires it.
+
+** Phase 3: Org-Safe Text Helpers
+
+Extract the calendar Org sanitizers and migrate calendar first.
+
+Then migrate consumers one at a time:
+
+- =org-webclipper.el=,
+- =ai-conversations.el=,
+- =mail-config.el= if mail-to-org behavior needs it.
+
+Exit criteria:
+
+- Existing calendar sanitizer tests pass under new helper names.
+- Calendar integration tests still pass.
+- New consumers have behavior tests showing external text cannot create
+ unintended headings/properties.
+
+** Phase 4: External Open Consolidation
+
+Pick a single owner for default system opener resolution.
+
+Then migrate:
+
+- =system-utils.el=,
+- =dirvish-config.el=,
+- =external-open.el=.
+
+Exit criteria:
+
+- One platform-opener decision point exists.
+- Dired/Dirvish/current-buffer open workflows still work.
+- Host tests cover Linux/macOS/Windows branches via predicate stubs.
+
+** Phase 5: Cache Abstraction
+
+Do this after simpler extractions, because cache abstraction is riskier.
+
+Suggested order:
+
+1. Write a Phase 5 design addendum with the exact cache API. The output of this
+ step is a design document, not code.
+2. Normalize agenda/refile cache code first.
+3. Add tests for rebuild, TTL, nil cache values, and error cleanup.
+4. Consider modeline VC cache only after global cache behavior is stable.
+
+Exit criteria:
+
+- Agenda/refile behavior is unchanged.
+- Building flags clear on errors.
+- Batch startup does not start extra timers/processes.
+- Cache helper does not obscure caller-specific rebuild logic.
+
+** Phase 6: Deferred And Opportunistic Helpers
+
+Consider lower-priority helpers only when a second consumer appears:
+
+- =cj/string-truncate-middle=,
+- =cj/read-file-string= / =cj/write-file-string=,
+- =cj/with-timer=,
+- recursive file deletion helpers,
+- warning-once helpers.
+
+* Test Strategy
+
+- Keep the project convention of focused helper tests.
+- Name new tests after the library/helper, for example:
+ - =tests/test-system-lib-executable-find-or-warn.el=
+ - =tests/test-system-lib-shell-quote-argument-readable.el=
+ - =tests/test-system-lib-process-output-or-error.el=
+ - =tests/test-system-lib-file-from-context.el=
+ - =tests/test-cj-org-text-sanitize-heading.el=
+- Move unit tests with extracted helpers.
+- Keep consumer tests that prove the original workflow still calls the helper
+ correctly.
+- Stub side-effectful primitives with =cl-letf=:
+ - =executable-find=,
+ - =display-warning=,
+ - =process-file=,
+ - Dired file-at-point helpers,
+ - host predicates.
+- For each extraction commit, run targeted tests for the helper and each touched
+ consumer. Run full =make test= before marking the task =VERIFY=.
+
+Interactive coverage:
+
+- For each migrated consumer with a keybinding, add or keep a test asserting
+ =(key-binding (kbd "..."))= resolves to the intended command symbol.
+- For non-trivial interactive flows such as =completing-read= prompts or
+ confirmation dialogs, use =with-simulated-input= where the helper is reachable
+ in batch.
+- Use =execute-kbd-macro= for non-graphical keypress flows where it is simpler
+ than stubbing command internals.
+- Mark visual-only checks, such as modeline appearance, theme rendering, and
+ font rendering, as manual smoke checks in the load-graph project rather than
+ utility helper tests.
+
+Coverage measurement:
+
+- Extracted helpers should meet the project's utility coverage target
+ (currently 90%) measured with =make coverage=.
+- Phase 5 cache work should meet the same target and include explicit
+ unwind/error-path tests for rebuilding flags and invalidation cleanup.
+
+Tooling: Cask, ert-runner, buttercup:
+
+- Keep this project on bare =emacs --batch= plus ERT because this repository is
+ a personal Emacs configuration, not a redistributable package.
+- =with-simulated-input= is acceptable as a test-only dependency for
+ interactive coverage when a migrated helper is reachable through a command
+ path.
+- Cask, ert-runner, buttercup, and ecukes are out of scope unless a future task
+ gives a concrete reason to adopt them.
+
+Header validation:
+
+- Add a smoke test that asserts =modules/system-lib.el= and any future
+ =modules/cj-*.el= library file declares the required library header lines.
+
+Test relocation policy:
+
+- The extraction commit moves the helper, helper unit tests, consumer call
+ sites, and consumer call-site test updates together.
+- Empty old helper test files are deleted in the same commit.
+- If an old consumer test file still has consumer behavior coverage, keep it
+ and remove only the helper-specific tests.
+- Avoid commits that only rename tests without moving behavior unless the rename
+ is too large to review with the helper extraction.
+
+* Acceptance Criteria
+
+This project is complete when:
+
+- =system-lib.el= has a documented role and contains only foundation-safe
+ helpers.
+- Each extracted helper has a stable public name, tests, and explicit consumer
+ =require= statements.
+- Feature modules no longer own generic executable lookup, process execution,
+ shell-readable quoting, or Org-safe text sanitation.
+- External open command resolution has a single owner.
+- Agenda/refile/modeline cache duplication has either been intentionally
+ consolidated or explicitly deferred with rationale.
+- No helper extraction introduces package/network/timer side effects at load
+ time.
+- Phase 2 and Phase 3 migrations record =emacs-init-time= against a phase
+ baseline; regressions around 25 ms or more should be investigated and
+ explained.
+- Full =make test= passes after the final migration.
+
+* Risks And Mitigations
+
+** Risk: Premature abstraction
+
+Mitigation:
+
+- Require at least two real consumers.
+- Keep "defer" as a valid inventory decision.
+- Avoid helpers named around vague concepts such as "do thing safely."
+
+** Risk: Foundation module becomes too broad
+
+Mitigation:
+
+- Keep =system-lib.el= dependency-light.
+- Split topic libraries when a helper family becomes stateful or domain-specific.
+- Track every new =require= added to =system-lib.el=.
+
+** Risk: Behavior changes during rename
+
+Mitigation:
+
+- Move tests first where practical.
+- Preserve old public symbols temporarily with aliases only when needed.
+- Change one helper family per commit.
+- Rename commits that preserve a one-cycle alias use =refactor:= and mention
+ the alias in the commit body; the alias does not need a separate commit.
+
+** Risk: Warnings become noisy at startup
+
+Mitigation:
+
+- Use warning helpers for user-invoked missing features.
+- Keep optional package =:if= checks quiet unless the user explicitly enabled
+ the feature.
+- Do not warn for every absent language tool on startup.
+
+** Risk: Helper calls in =use-package :if= create new load-order requirements
+
+Mitigation:
+
+- Do not migrate =use-package :if= clauses in this project. Keep built-in
+ predicates such as =executable-find= there unless a load-graph task handles
+ the ordering explicitly.
+- Migrate function-body, command-body, and =:config= callers first, where
+ =require 'system-lib= can be ordinary and local.
+- If a future migration needs a helper in =:if=, document the load-order
+ prerequisite in that commit and ensure =system-lib= is required before the
+ =use-package= form is macroexpanded/evaluated.
+
+** Risk: Process helper hides security decisions
+
+Mitigation:
+
+- Prefer argv APIs over shell command strings.
+- Keep shell-string helpers clearly separate from process-file helpers.
+- Document when a caller intentionally uses a shell.
+
+** Risk: Helper API turns out to be wrong
+
+Mitigation:
+
+- Revert the extraction commit, restore the source-module helper name and tests,
+ and file a redesign task.
+- Do not patch a vague or wrong foundation API in place after consumers have
+ started migrating; stabilize the API before the second wave of consumers.
+
+* Open Questions
+
+- Should =system-lib.el= eventually become only an aggregator requiring topic
+ libraries, or remain the primary helper file?
+- Should =system-utils.el= and =config-utilities.el= remain separate command
+ modules after library extraction? Default answer for this project: keep them
+ separate; revisit only during the load-graph refactor.
+- What should the cache helper represent: a struct, a plist, closures, or
+ caller-owned variables plus helper predicates?
+
+Closed alias check:
+
+- A local search across =/home/cjennings/code=, =/home/cjennings/projects=,
+ =/home/cjennings/go=, and this repository found no uses of
+ =cj/executable-exists-p= or =cj/log-silently= outside this Emacs
+ configuration. Alias decisions are therefore for in-repo compatibility and
+ user muscle memory, not external package consumers.
+
+* Recommended First Three Commits
+
+Phase 2 also extracts =cj/file-from-context= as commit 4. It is not in this
+first-three list because it has direct multi-consumer pressure and is not a
+speculative extraction; the three commits below are the speculative or
+high-policy commits that need extra care.
+
+1. Extract =cj/executable-find-or-warn= to =system-lib.el=.
+ - Migrate =mail-config.el=.
+ - Add =tests/test-system-lib-executable-find-or-warn.el=.
+ - Keep optional language/package checks unchanged.
+2. Extract =cj/shell-quote-argument-readable= to =system-lib.el=.
+ - Migrate =dev-fkeys.el=.
+ - Move F6 shell-quote tests or add focused system-lib tests.
+ - Do not replace all =shell-quote-argument= callers yet.
+ - This is a wrong-layer speculative extraction; record expected future
+ consumers in the inventory.
+3. Extract =cj/process-output-or-error= and =cj/git-output-or-error=.
+ - Migrate =coverage-core.el= only.
+ - Keep coverage-specific git wrappers in =coverage-core.el=.
+ - Add tests that stub =process-file=.
+ - This is a wrong-layer speculative extraction; record expected future
+ consumers in the inventory.
+
+These give the project useful proof points without touching stateful cache
+behavior or broader load-order mechanics.
diff --git a/docs/specs/vterm-to-ghostel-migration-spec-implemented.org b/docs/specs/vterm-to-ghostel-migration-spec-implemented.org
new file mode 100644
index 000000000..1be4fe227
--- /dev/null
+++ b/docs/specs/vterm-to-ghostel-migration-spec-implemented.org
@@ -0,0 +1,424 @@
+:PROPERTIES:
+:ID: b54c94a0-d762-4b41-afd7-cf5593ce6675
+:STATUS: implemented
+:END:
+#+TITLE: Migration: vterm → ghostel (single terminal engine)
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-04
+
+* Status
+
+READY. Review incorporated (external review, 2026-06-04). Supersedes the
+EAT-consolidation direction in =todo.org= (task "Migrate all terminals from
+vterm to ghostel"). Research background:
+[[file:../2026-05-25-emacs-terminal-comparison.org][docs/2026-05-25-emacs-terminal-comparison.org]].
+
+* Goal
+
+Replace vterm with [[https://github.com/dakra/ghostel][ghostel]] (a native Emacs module over libghostty-vt, the
+Ghostty terminal engine) as the single terminal engine across every
+workflow, and rename the AI-agent launcher =ai-vterm= → =ai-term=. When the
+migration lands, vterm and vterm-toggle are removed from the config.
+
+Why ghostel over the prior EAT plan: ghostel is the most faithful Claude
+Code TUI renderer and the fastest engine (≈81 vs vterm 34 vs eat 4.9 MB/s),
+and an audit confirmed it exposes an analog for every vterm primitive this
+config uses. EAT's washed colors, its scroll-pop / stuck-input bug under
+Claude Code, and its slowest throughput made it the weaker single-engine
+pick. One engine beats running two.
+
+* What the spike established (2026-06-04, read-only)
+
+Sandbox: =/tmp/ghostel-spike= via =emacs --init-directory=, nothing touched
+in the real config. Emacs 30.2 GTK, x86_64, modules supported.
+
+- *Install / multi-machine*: ghostel installs from MELPA; the native module
+ auto-downloaded (=v0.33.0 ghostel-module-x86_64-linux.so=) and loaded with
+ no toolchain. Confirms the per-machine prebuilt-binary story works
+ (x86_64-linux covers velox). Only a non-prebuilt arch needs the Zig build.
+- *tmux pty (linchpin)*: a spawned ghostel reports =process-tty-name
+ "/dev/pts/1"=. The tmux pane-id lookup in the current vterm-config keys on
+ exactly that, so the tmux copy-mode / history machinery ports unchanged.
+- *Colorization model*: =ghostel-color-palette= is a vector of 16 named
+ faces, but =ghostel--apply-palette= RESOLVES those faces (+ =ghostel-default=
+ fg/bg) to hex and pushes them to the native module
+ (=ghostel--set-palette= / =ghostel--set-default-colors=); the module bakes
+ the colors into the grid. Theme changes are handled by =ghostel-sync-theme=
+ (hooked to =enable-theme-functions= / =load-theme= advice), which
+ re-resolves and re-pushes. *Consequence:* buffer-local =face-remap= does
+ NOT dim a ghostel buffer, and there is no per-window color hook. This
+ drives Decision D1.
+- *TTY frames*: no =display-graphic-p= / =window-system= guards in
+ =ghostel.el= — it renders text + faces and only kitty inline-graphics
+ degrade in a TTY. ghostel works in terminal frames (Decision D4).
+- *copy-mode*: a read-only input-mode toggle (=ghostel-copy-mode=) with
+ standard Emacs nav / mark; =q= / =C-g= exit, =M-w= copies and stays. The
+ vterm copy-mode contract maps near-free.
+- *F8 / key forwarding (diagnostic)*: ghostel's default semi-char mode
+ forwards unlisted keys to the terminal program; only
+ =ghostel-keymap-exceptions= (default =C-c C-x C-u C-h M-x M-: C-\=) reach
+ Emacs. Unlike vterm, binding F9/F12 in =ghostel-mode-map= is NOT enough:
+ =ghostel-semi-char-mode-map= is rebuilt from =ghostel-keymap-exceptions= and
+ outranks the major-mode map, so a key not in the exceptions is sent to the
+ pty before the mode-map binding can fire. The F9 family, F12, and C-; must be
+ added to =ghostel-keymap-exceptions= AND the semi-char map rebuilt
+ (=ghostel--rebuild-semi-char-keymap=; =add-to-list= alone updates the list
+ but not the already-built map). (Shipped wrong in the first cut — F9 did
+ nothing in agent buffers until the keys were added to the exceptions.)
+- *GUI / TTY visual*: Craig confirmed the Claude Code TUI and a TTY frame
+ both render great. dupre chrome applies; the 16 ANSI terminal faces are
+ ghostel defaults (dupre does not theme them) — Decision D2.
+
+* Agreed decisions
+
+All confirmed by Craig 2026-06-04 (incorporating the external review).
+
+- *D1 — auto-dim*: terminal buffers do NOT participate in unfocused-window
+ dimming in v1. =auto-dim-config.el= drops its entire vterm integration
+ (~140 lines of =vterm--get-color= advice + redraw scheduling). Rationale:
+ ghostel bakes the palette per-terminal, not per-window, so vterm's
+ per-window dim is not achievable; a buffer-wide palette re-push on
+ focus-loss is more code, forces repaints, and only works when the buffer is
+ in one window — not worth it.
+- *D2 — dupre ANSI palette*: a follow-up, not v1. The 16 =ghostel-color-*=
+ faces (+ =ghostel-default=) get themed in dupre later, unless the engine
+ swap exposes visibly poor colors during verification.
+- *D3 — eshell*: out of scope. =ghostel-eshell= adoption is a separate
+ follow-up task; eshell stays the shell.
+- *D4 — TTY refuse-guard*: dropped. =cj/--ai-vterm-refuse-in-terminal= and
+ its echo-area refusal message are removed; F9 launches in a terminal frame.
+ Its manual-verify test is removed too (it asserted the refusal).
+- *D5 — module names*: =vterm-config.el= → =term-config.el=; =ai-vterm.el= →
+ =ai-term.el=; =cj/vterm-*= → =cj/term-*=; =cj/ai-vterm-*= → =cj/ai-term-*=.
+ The "agent [" buffer prefix is unchanged.
+- *D6 — module-failure behavior*: ghostel degrades with a warning rather than
+ failing startup. Load it guarded (=(require 'ghostel nil t)=) and, on
+ failure, emit a =display-warning= and leave the terminal commands defined
+ but inert. Rationale: the daemon serves many frames across machines, and
+ the project idiom is graceful degradation (the =(when (require 'foo nil t)
+ ...)= rule and =cj/executable-find-or-warn=); hard-failing startup on a
+ machine missing the prebuilt module is worse than a warned degrade. Tests
+ stub ghostel and never require the native module. (Modifies the reviewer's
+ recommendation of "fail loudly" — see Review dispositions.)
+- *D7 — scrollback value*: =ghostel-max-scrollback= set to =10 MB= (=(* 10
+ 1024 1024)=) as a defcustom, the byte analog of the prior =100000=-line
+ intent (~100 bytes/line). Verified under heavy output during manual
+ testing.
+
+* Primitive mapping (vterm → ghostel)
+
+| vterm | ghostel | note |
+|--------------------------------+-------------------------------------------+------|
+| =(vterm NAME)= | =(ghostel)= + rename to NAME | via =ghostel-buffer-name-function= or post-create rename |
+| =vterm-send-string= | =ghostel-send-string= | public; confirmed |
+| =vterm-send-return= | =(ghostel-send-string "\n")= | |
+| =vterm-mode= | =ghostel-mode= | all major-mode checks |
+| =vterm-mode-map= | =ghostel-mode-map= | F9 + F12 rebind, C-; install |
+| =vterm-keymap-exceptions= | =ghostel-keymap-exceptions= | add =C-;= |
+| =vterm-copy-mode= | =ghostel-copy-mode= | read-only input mode |
+| =vterm-copy-mode-map= bindings | input-mode (q/C-g exit, M-w copies-stays) | near-free parity |
+| =vterm-clear-scrollback= | =ghostel-clear-scrollback= | C-; x l |
+| =vterm-next-prompt= | =ghostel-next-prompt= | C-; x n |
+| =vterm-previous-prompt= | =ghostel-previous-prompt= | C-; x p |
+| =vterm-send-next-key= | =ghostel-send-next-key= | C-; x q |
+| =vterm-yank= | =ghostel-yank= | C-y |
+| =vterm-reset-cursor-point= | drop (renderer owns point) | decided: no analog needed |
+| =vterm-other-window= | =(ghostel)= + other-window display | thin wrapper |
+| =vterm-max-scrollback= (lines) | =ghostel-max-scrollback= = 10 MB (D7) | unit change lines→bytes |
+| =vterm-kill-buffer-on-exit= | =ghostel-kill-buffer-on-exit= | |
+| =vterm-timer-delay= (nil hack) | =ghostel-timer-delay= / adaptive-fps | hacks DROP |
+| =cj/vterm--send-mouse-wheel= | drop (ghostel forwards SGR natively) | net deletion; verify under tmux/Claude/lazygit |
+| =cj/vterm-send-escape= | =(ghostel-send-string "\e")= if needed | re-check =<escape>= global conflict |
+| =vterm--get-color= advice | none (D1) | auto-dim integration deleted |
+| =vterm-always-compile-module= | =ghostel-module-auto-install= | + D6 guarded load |
+| tmux pane-id via =process-tty-name= | unchanged | confirmed /dev/pts |
+
+* Surface to change
+
+Audited file set.
+
+** Main modules
+- =modules/vterm-config.el= (~540L) → =modules/term-config.el=. Ports with
+ renamed primitives; deletes the mouse-wheel forwarding and the
+ =vterm-timer-delay= hacks; renames =cj/vterm-*= → =cj/term-*= (no
+ compatibility shim). Keeps the tmux history / copy-mode-dwim logic pure
+ around =process-tty-name= and =tmux= process calls (engine-agnostic — the
+ part most worth preserving). =cj/vterm-map= (C-; x) → =cj/term-map=;
+ which-key label "vterm menu" → "terminal menu".
+- =modules/ai-vterm.el= (~978L) → =modules/ai-term.el=. Only ~6 call sites
+ are vterm-specific (=vterm= / =vterm-send-string= / =vterm-send-return=,
+ the suppress-tmux coupling, the =vterm-mode-map= F9 rebind, the
+ declare-functions). The ~970L of picker / MRU / crash-recovery / display
+ chain / dispatch / geometry is engine-agnostic and renames cleanly
+ (=cj/ai-vterm-*= → =cj/ai-term-*=). Buffer prefix "agent [" stays. The
+ refuse-in-terminal guard is deleted (D4).
+
+ *tmux-suppression invariant (contract).* =cj/--ai-term-show-or-create= must
+ preserve exactly one tmux launch path for agent buffers: the dynamic
+ binding of the suppress flag around =(ghostel)= keeps the generic
+ auto-tmux hook from sending a bare =tmux\n= before the project-named
+ =tmux new-session -A= command runs. Porting must not introduce a second
+ launch path.
+
+** Satellites
+- =modules/auto-dim-config.el= — per D1, delete the vterm color advice +
+ redraw scheduling entirely (no ghostel replacement in v1).
+- =modules/ui-config.el= — =vterm-mode= / =vterm-copy-mode= cursor/modeline
+ check → ghostel equivalents (live ghostel = writeable cursor state;
+ =ghostel-copy-mode= = read-only).
+- =modules/dashboard-config.el= — launcher lambda → =(ghostel)=; label
+ "Launch VTerm" → "Launch Terminal".
+- =modules/cj-window-geometry-lib.el=, =modules/cj-window-toggle-lib.el= —
+ vterm only in comments; update doc references.
+- =init.el= — =(require 'ai-vterm)= → =(require 'ai-term)=; add term-config
+ require (guarded per D6).
+
+** Docs (active references only — historical notes stay)
+- =todo.org= current task link (already updated to this -spec path).
+- =docs/design/module-inventory.org=, =docs/specs/init-load-graph-spec-doing.org= —
+ update active =vterm-config= / =ai-vterm= references to the new names.
+
+** Tests (~35 files)
+- 24 =test-ai-vterm--*.el= are mostly engine-agnostic logic (buffer-name,
+ candidates, sort, dispatch, geometry, MRU) → rename to =test-ai-term--*.el=
+ mechanically, only after a green baseline; assertions stand.
+- Coupled, need rework: =testutil-vterm-buffers.el= (→ stub ghostel),
+ =test-ai-vterm--f9-in-vterm.el=, =test-ai-vterm--show-or-create.el=,
+ =test-vterm-copy-mode-cursor.el=, =test-vterm-tmux-history.el=,
+ =test-vterm-toggle--*.el= (×3).
+- Cross-cutting touch: =test-auto-dim-config.el= (delete vterm-integration
+ tests per D1), =test-ui-config--buffer-cursor-state.el=,
+ =test-dashboard-config-launchers.el=, =test-init-module-headers.el=,
+ =test-cj-window-toggle-lib.el=.
+
+* Dependency / module failure behavior (D6)
+
+- ghostel is a required MELPA package. It loads guarded:
+ =(unless (require 'ghostel nil t) (display-warning 'term "..."))=.
+- On a prebuilt arch the native module auto-downloads
+ (=ghostel-module-auto-install=). On a non-prebuilt arch the user installs
+ Zig 0.15.2 and builds per ghostel's instructions; until then the warning
+ fires and terminal commands are inert (defined but no-op / user-error),
+ never breaking startup or other frames.
+- Tests stub ghostel in the test-util layer and never require the native
+ module, so the suite runs on any machine and in CI/batch.
+
+* Key & menu ownership (per phase)
+
+To avoid order-dependent duplicate bindings, ownership transfers cleanly:
+
+- *Before*: =vterm-config= owns F12, =C-; x=, the vterm display rule, and the
+ which-key labels.
+- *Phase 1*: =term-config= is added and immediately becomes the owner of F12
+ and =C-; x= (and the terminal display rule). =vterm-config= is no longer
+ required, so its bindings do not co-install. The vterm package remains
+ installed only as a fallback engine until Phase 4.
+- *Phase 2*: =ai-term= owns the F9 family (global + in =ghostel-mode-map=);
+ =ai-vterm= is no longer required.
+- *Phase 4*: vterm / vterm-toggle packages removed; no vterm ownership
+ remains anywhere.
+
+* Implementation phases (TDD, green at each step)
+
+Each phase is a shippable deliverable; the suite + byte-compile stay green at
+every step.
+
+- *Phase 0 — characterization baseline.* Before any port, add/confirm
+ characterization tests for the behaviors that must survive: F12
+ dispatch/display, tmux pane-id + history-buffer replacement, AI
+ show-or-create tmux launch command, F9 from inside terminal mode,
+ cursor-state classification, dashboard launcher action. Green baseline.
+ Deliverable: characterization tests committed; no behavior change.
+- *Phase 1 — ghostel + term-config.* Add ghostel (use-package, MELPA,
+ guarded per D6). New =term-config.el= owning F12, =cj/term-map= (C-; x),
+ copy-mode parity, tmux history/copy-mode-dwim (pure =process-tty-name=
+ path), which-key "terminal menu", =ghostel-max-scrollback= 10 MB,
+ =ghostel-keymap-exceptions= incl. =C-;=. =vterm-config= dropped from the
+ require list (ownership transfers). Tests for the new module + ghostel
+ stubs. Deliverable: F12 general terminal runs on ghostel.
+- *Phase 2 — ai-term.* Rename =ai-vterm.el= → =ai-term.el=; swap the ~6 vterm
+ call sites to ghostel; F9/C-F9/M-F9 on global + =ghostel-mode-map=; drop
+ the refuse-in-terminal guard (D4); preserve the tmux-suppression invariant.
+ Rename engine-agnostic tests to =test-ai-term--*= (after green); rework the
+ coupled ones; add D4 regression tests (no refusal path; F9 installed in
+ =ghostel-mode-map=) and a negative test that agent buffers are excluded
+ from F12 toggling under the new names. Deliverable: agents run on ghostel.
+- *Phase 3 — satellites.* auto-dim vterm integration deleted (D1);
+ ui-config cursor/modeline check ported; dashboard launcher + label;
+ geometry/toggle-lib doc refs; init.el requires; active doc references.
+ Deliverable: no module references vterm except the package itself.
+- *Phase 4 — remove vterm.* Delete vterm + vterm-toggle packages, dead
+ config, the mouse-wheel / timer hacks. Full test sweep + byte-compile +
+ manual smoke after a daemon restart (the restart is an acceptance gate —
+ see below). Deliverable: vterm gone; ghostel is the only terminal engine.
+
+** Follow-up / vNext (not this series)
+- D2 — theme the 16 =ghostel-color-*= + =ghostel-default= faces in dupre.
+- D3 — evaluate =ghostel-eshell= as eshell's visual backend.
+- Evaluate =ghostel-compile= against the F4 dev-fkeys compile flow.
+- =ghostel-comint= for =M-x shell= / REPL output fidelity (optional).
+
+* Acceptance criteria
+
+The migration is complete when all hold:
+
+1. =init.el= requires =term-config= and =ai-term=; nothing in the config
+ requires =vterm-config= or =ai-vterm=.
+2. vterm / vterm-toggle packages and their keybindings are removed, after
+ ghostel parity is green.
+3. F12 normal-terminal toggle excludes agent buffers and preserves saved
+ geometry.
+4. F9 / C-F9 / M-F9 work from normal buffers AND inside =ghostel-mode=
+ buffers.
+5. AI project launch reuses/reattaches the named =aiv-= tmux session and does
+ NOT receive the generic auto-tmux launch.
+6. =C-; x c= and =C-; x h= preserve the tmux copy / history behavior.
+7. Live ghostel buffers report a writeable cursor state; ghostel copy-mode
+ reports read-only.
+8. Terminal-frame F9 launches (the refusal path and its test are gone).
+9. ghostel-unavailable degrades with a warning, not a startup failure (D6).
+10. Full test suite, byte-compile, and manual smoke all pass after a daemon
+ restart.
+
+* Test strategy
+
+- *Characterization first* (Phase 0): capture current behavior before porting
+ so parity is measurable.
+- *Stub ghostel* in the test-util layer; tests never require the native
+ module (runs in batch/CI on any machine).
+- *Rename mechanically after green*: only rename engine-agnostic
+ =test-ai-vterm--*= → =test-ai-term--*= once the baseline is green.
+- *Regression tests for D4*: no terminal-frame refusal path remains; F9
+ bindings are installed in =ghostel-mode-map=.
+- *Negative test*: agent buffers are excluded from F12 normal-terminal
+ toggling under the new buffer/mode names.
+
+* Manual-verify test matrix
+
+Per =verification.md=, filed under "Emacs Manual Testing and Validation" at
+Phase 4, run again after a daemon restart. Each: steps + expected.
+
+- Claude Code TUI in ghostel (GUI): colors true, flicker-free under heavy
+ stream, box-drawing + cursor correct.
+- Claude Code TUI in a TTY frame (velox-style =emacs -nw=): renders as
+ text+color, layout intact; inline images absent (expected).
+- F9 / C-F9 / M-F9 dispatch: toggle, pick-project, close — same behavior as
+ the vterm era, on ghostel, including from a terminal frame (now launches).
+- tmux: agent launches in its named session; second F9 reattaches; close
+ kills the session; =C-; x h= captures tmux history; =C-; x c= enters tmux
+ copy-mode.
+- copy-mode parity: =M-w= copies and stays, =q= / =C-g= exit.
+- mouse wheel inside tmux / Claude Code / lazygit scrolls correctly (this was
+ a prior explicit vterm fix being removed — confirm ghostel's native SGR
+ forwarding covers it).
+- lazygit, htop/btop, a heavy-output build, ssh to a remote: render + behave.
+- Crash recovery: kill Emacs with a live =aiv-= tmux session, restart, the
+ picker flags it =[detached]= and reattaches.
+
+* Risks / notes
+
+- *Daemon module reload*: a loaded native module needs a daemon restart to
+ upgrade; the Phase 4 restart is an acceptance gate before deleting vterm
+ (plus the gold-standard full-launch smoke per CLAUDE.md after =:config=
+ edits).
+- *Buffer naming*: forcing "agent [basename]" goes through
+ =ghostel-buffer-name-function= or a post-create rename — confirm the exact
+ hook in Phase 2.
+- *<escape> global rebind*: vterm needed a custom escape forwarder because
+ =<escape>= is globally =keyboard-escape-quit=; re-check whether ghostel in
+ semi-char mode forwards it or needs the same treatment.
+- *ssh terminfo*: ghostel advertises =TERM=xterm-ghostty=; outbound ssh to
+ hosts lacking that terminfo may need =ghostel-ssh-install-terminfo= or a
+ fallback =ghostel-term=. Covered by the ssh manual-verify row.
+- *ANSI palette*: until D2 lands, terminal ANSI colors are ghostel defaults.
+
+* Implementation tasks (drop-in for todo.org)
+
+#+begin_src org
+*** TODO [#B] Phase 0: terminal characterization baseline :terminal:ghostel:tests:
+Characterization tests for F12 dispatch/display, tmux pane-id + history replacement, AI show-or-create launch command, F9-in-terminal, cursor-state classification, dashboard launcher. Green baseline, no behavior change.
+*** TODO [#B] Phase 1: add ghostel + term-config.el :terminal:ghostel:
+ghostel use-package (MELPA, guarded per D6); term-config.el owns F12 + C-; x + copy-mode + tmux history; which-key "terminal menu"; ghostel-max-scrollback 10MB; C-; in ghostel-keymap-exceptions. Drop vterm-config from requires. Tests + ghostel stubs.
+*** TODO [#B] Phase 2: rename ai-vterm→ai-term on ghostel :terminal:ghostel:
+Swap the 6 vterm call sites; F9 family on global + ghostel-mode-map; drop refuse-in-terminal guard (D4); preserve tmux-suppression invariant. Rename engine-agnostic tests after green; rework coupled tests; add D4 + F12-excludes-agent regression tests.
+*** TODO [#B] Phase 3: port satellites to ghostel :terminal:ghostel:
+Delete auto-dim vterm integration (D1); port ui-config cursor check; dashboard launcher + "Launch Terminal" label; geometry/toggle-lib doc refs; init.el requires; module-inventory + init-load-graph doc refs.
+*** TODO [#B] Phase 4: remove vterm and vterm-toggle :terminal:ghostel:
+Delete packages + dead config + mouse-wheel/timer hacks. Full suite + byte-compile + manual smoke after daemon restart (acceptance gate). Run the manual-verify matrix.
+*** TODO [#C] Follow-up: theme ghostel ANSI faces in dupre :terminal:ghostel:dupre:
+D2 — set the 16 ghostel-color-* + ghostel-default faces in dupre-faces/palette.
+*** TODO [#C] Follow-up: evaluate ghostel-eshell + ghostel-compile :terminal:ghostel:eval:
+D3 — ghostel-eshell as eshell visual backend; ghostel-compile against F4 dev-fkeys.
+#+end_src
+
+* Review dispositions
+
+Only the *modified* recommendations are listed; everything else in the external
+review was accepted as written.
+
+- *Module-failure behavior (modified).* The reviewer recommended ghostel be required
+ and "startup may fail loudly" if the package/module can't load. Modified to
+ degrade-with-warning (D6): guarded require + =display-warning=, terminal
+ commands inert, startup unaffected. Reason: the daemon serves many frames
+ across machines and the project idiom is graceful degradation; hard-failing
+ startup on a machine missing the prebuilt module is worse than a warned
+ degrade. The rest of the recommendation (ghostel required; non-prebuilt
+ needs Zig; tests stub the module) is accepted.
+- *Scrollback value (modified → concretized).* The reviewer asked for a concrete
+ byte value or a defcustom. Chose =10 MB= as a defcustom (D7), the byte
+ analog of 100000 lines, verified under heavy output. (Not a disagreement —
+ filling the gap the review flagged.)
+
+Everything else accepted as written: D1-D5 baked as Agreed decisions;
+Implementation phases + Acceptance criteria + Dependency-failure + Test
+strategy sections added; key/menu ownership made explicit per phase;
+tmux-suppression stated as a contract; UX changes (TTY-refusal removal,
+"Launch Terminal", "terminal menu"); architecture (rename =cj/vterm-*= →
+=cj/term-*=, keep tmux fns pure, no vterm-private-redraw port); doc cleanup
+for active references; mouse-wheel manual verify; daemon-restart acceptance
+gate.
+
+* Review and iteration history
+
+** 2026-06-04 Thursday @ 23:17:54 -0500 — reviewer
+
+- *What changed or was recommended:* Ran the spec-review workflow after
+ renaming this file to the required =-spec.org= suffix. Wrote a companion
+ review with a =Not ready= rubric: D1-D5 still need acceptance, the handoff
+ needs an =Implementation phases= section, acceptance criteria are missing,
+ and ghostel package/native-module failure behavior needs an explicit v1
+ contract.
+- *Why:* The migration direction is sound, but the current draft still leaves
+ implementation-affecting decisions and completion criteria for the builder
+ to infer.
+- *Artifacts:* review file (deleted on incorporation).
+
+** 2026-06-04 Thursday @ 23:24:28 -0500 — responder
+
+- *What changed:* Incorporated the external review via the spec-response
+ workflow. Craig accepted D1-D5; baked them (plus D6 module-failure and D7
+ scrollback) into a new "Agreed decisions" section and out of "Open
+ decisions." Added Implementation phases, Acceptance criteria, Dependency /
+ module failure behavior, Test strategy, explicit per-phase key/menu
+ ownership, the tmux-suppression contract, and an Implementation-tasks
+ drop-in block. Applied the UX, architecture, doc-cleanup, and
+ manual-verify additions. Status raised DRAFT → READY.
+- *Why:* Close the "Not ready" findings — resolve the open decisions,
+ give the builder phases + acceptance criteria, and define
+ ghostel-unavailable behavior — so a reader can implement from this file.
+- *Modified vs the review:* module-failure = degrade-with-warning, not
+ fail-loud (D6 rationale); scrollback concretized to 10 MB (D7). See Review
+ dispositions. Everything else accepted as written.
+- *Artifacts:* This spec; review file deleted; =todo.org= task link updated.
+
+** 2026-06-04 Thursday @ 23:30:18 -0500 — reviewer
+
+- *What changed or was recommended:* Re-reviewed the incorporated spec and
+ assigned a =Ready= rubric. No further blocking review notes. The prior
+ blockers are closed: D1-D7 are accepted decisions, implementation phases and
+ acceptance criteria are present, ghostel-unavailable behavior is explicit,
+ key/menu ownership is phased, and implementation tasks are enumerated.
+- *Why:* Confirm the spec-response pass left an implementable handoff rather
+ than just adding prose.
+- *Artifacts:* This history entry; no new review file because the spec is
+ implementation-ready.