#+TITLE: Emacs Config
#+AUTHOR: Craig Jennings
#+ARCHIVE: %s::* Emacs Resolved
* Emacs Priority Scheme
Use priority to express impact and urgency, not task type. Bugs, refactors,
tests, chores, and features can all be high or low priority.
- =[#A]= Urgent risk or current workflow blocker. Use for credential exposure,
security/privacy leaks, data loss, destructive behavior, startup breakage,
failing tests that block work, or a feature/refactor that unblocks a core
daily workflow.
- =[#B]= Important planned work. Use for concrete bugs, high-leverage
architecture cleanup, brittle load-order/test gaps, dependency failures, or
feature work with a clear design and expected near-term use.
- =[#C]= Useful but optional. Use for low-risk cleanup, ergonomics, smoke tests,
investigations with limited current impact, or feature work that would improve
the setup but is not yet a committed workflow.
- =[#D]= Someday/maybe or watchlist. Use for speculative features, tiny polish,
upstream/package tracking, optimizations without current pain, or deferred
ideas that should not compete with active maintenance.
For =PROJECT= headings, use the highest priority of the meaningful child work
inside the project. If a project only contains exploration or review, assign the
priority by the expected decision value rather than the number of files touched.
Use tags to describe the work shape:
- =:bug:= means the current behavior is wrong or likely broken.
- =:feature:= means the task adds a new user-visible capability or workflow.
- =:refactor:= means the task changes structure/ownership without primarily
changing behavior.
- =:quick:= means the task appears low effort and localized. It is a planning
hint, not a promise; remove it if the task grows during implementation.
- =:solo:= means Claude can do the task end to end with no input from Craig:
bounded scope, no design or preference call, and verifiable in the local
setup (tests, byte-compile, launch). Tasks needing a policy/preference
decision, visual judgment, or a live remote do not get =:solo:=.
Tags are additive. For example, a small wrong-behavior fix can be
=:bug:quick:=, and a feature that requires internal restructuring can be
=:feature:refactor:=.
* Emacs Open Work
** DOING [#B] Signal client — forked signel :feature:
Parent task for the Emacs Signal client. Engine: signal-cli (linked secondary device). Front end: a fork of signel at =~/code/signel=, wired through =modules/signal-config.el=. Design: [[file:docs/design/signal-client.org][docs/design/signal-client.org]]. Child issues below.
*** 2026-05-26 Tue @ 20:06:58 -0500 Decided: fork signel rather than depend on it
signel is on MELPA but stale (one-author v0.1, all commits in a Jan-2026 burst, unattended tracker, no PRs). The spec needs internal edits (notify behavior, input-clobber fix), which are clean in a fork and hacky via advice, and a dead upstream means no divergence cost. Rejected: adopt-from-MELPA + advice, build-from-scratch, signal-cli-rest-api (Docker), MCP-tool, ERC bridge. Full rationale in the design doc.
*** 2026-05-26 Tue @ 20:06:58 -0500 Linked as secondary device; contact parser verified against live shape
Installed signal-cli 0.14.4.1 (AUR; imported AsamK's signing key FA10826A... to clear the makepkg verification). Linked the account via QR. Built and unit-tested the pure helper layer in =modules/signal-config.el= (contact-list parsing, notify-when-not-viewing predicate) with =tests/test-signal-config.el=. Confirmed the live =listContacts= shape: givenName/familyName are top-level in 0.14, not under profile as first assumed; corrected the parser and verified it produces a picker entry for all 94 real contacts. Sent a request to archsetup to add signal-cli to the standard install.
*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped initiate-message workflow: picker + Note-to-Self + keymap
=cj/signel-message= (=C-; M m=) names contacts via =completing-read= over the cj-owned =cj/signel--contact-cache=, with "Note to Self" pinned first. =cj/signel-message-self= (=C-; M s=) sends straight to =signel-account=. Daemon guard =cj/signel--ensure-started= auto-starts the daemon when =signel-account= is set and =user-error='s with the remedy when it isn't; on start it pre-warms the cache. =cj/signel--fetch-contacts= rides the new RPC callback contract (=signel--send-rpc= with success-callback), the result feeds =cj/signal--parse-contacts=, and =cj/signel-refresh-contacts= (=C-; M no leaf=) clears + refetches. Cold-cache invocations =accept-process-output= up to =cj/signel-fetch-timeout= seconds (3s default) and =user-error= on timeout so a wedged daemon can't hang Emacs. Prefix keymap =cj/signel-prefix-map= bound under =C-; M= via =keybindings.el='s =cj/custom-keymap=: m / s / d / q / SPC. 15 new ERT tests in =tests/test-signal-config.el= cover ensure-started branches, fetch contract, cache empty-vs-failure, refresh, picker happy-path + cold-cache resolves + cold-cache timeout, message-self, and the prefix map bindings.
*** 2026-05-27 Wed @ 21:55:57 -0500 Added JSON-RPC success-result dispatch in the signel fork
Fork commit 4740d97 added =signel--request-handler-map= (id → success callback), extended =signel--send-rpc= with an optional =success-callback= that registers under the new request id, and gave =signel--dispatch= a result branch that invokes the callback and removes the handler. Error responses also remhash the handler entry, and =signel-start= / =signel-stop= both =clrhash= the map so reconnect is reliably empty. Backward-compatible: existing callers that don't pass a callback hit the same code path as before. Five ERT tests in this project (=tests/test-signel-rpc-dispatch.el=, dotemacs commit bfec0eab) lock the contract: Normal (result invokes callback + cleanup, send-rpc registers), Boundary (unknown id is a no-op), Error (error response cleans up handler), reconnect (=signel-stop= empties the map). Refactor audit surfaced a separate pre-existing leak in =signel--handle-error= (request-buffer-map entries aren't removed on error); filed as the [#C] follow-up below.
*** TODO [#C] signel--handle-error leaks request-buffer-map entries :bug:
Surfaced during the JSON-RPC dispatch refactor audit. =signel--handle-error= reads =signel--request-buffer-map= by id but never =remhash='es the entry, so every error response leaves the request-id → buffer-name mapping behind for the life of the process. Low impact (the map clears on stop/start, and id collisions are unlikely at the counter scale), but unbounded growth in a long-lived session and inconsistent with how the new request-handler-map is cleaned up on error.
*** TODO [#B] Notify only for the unviewed conversation :feature:
Wire =cj/signal--should-notify-p= (done) into signel's =signel--handle-receive= notify block (signel.el:277), route through Craig's notify script instead of bare =notifications-notify=, and gate sound behind a defcustom that defaults off.
*** 2026-05-27 Wed @ 22:08:40 -0500 Shipped clobber fix for both insert paths
Fork commit 5ec56c0 added =signel--pending-input= (capture from input-marker to point-max) and =signel--restore-input= (re-insert after the redrawn prompt; nil-safe), and wired both into =signel--insert-msg= (the receive path) and =signel--insert-system-msg= (the error path). A mid-type send now survives both an incoming message and a system-error insertion. Four ERT tests in =tests/test-signel-input-preservation.el= cover the helpers (typed text, empty) and both insert paths via a temp =signel-chat-mode= buffer.
*** TODO [#B] Link command with QR :feature:
=cj/signel-link= wrapping =signal-cli link -n NAME=, capturing the =sgnl://linkdevice= URI and rendering it as a scannable QR (qrencode). Convenience for re-linking; the first link was done by hand this session.
*** 2026-05-27 Wed @ 22:08:40 -0500 use-package wired with C-; M keymap and local account config
=use-package signel :load-path "~/code/signel" :ensure nil= already wired earlier with =signel-auto-open-buffer nil=. Account source is =signel-account= set from =cj/signal-private-config-file= (=signal-config.local.el=, gitignored) loaded in =:config=, decided in the workflow spec. Keymap prefix =C-; M= attached via =with-eval-after-load 'keybindings= so the binding survives load-order.
*** TODO [#D] Include Signal groups in the picker :feature:
vNext after the 1:1 initiate-message flow is stable. Merge =listGroups= with =listContacts=, label groups distinctly, and preserve the current v1 behavior where the picker is contacts-only.
*** 2026-05-26 Tue @ 15:15:43 -0500 Candidate Signal clients / CLIs
Signal has no official API, so everything below is unofficial and can break on Signal-Server changes (signal-cli notably expires after about three months without updates). All link as a secondary device to an existing phone, the safer model.
- [[https://github.com/AsamK/signal-cli][signal-cli]] — CLI/daemon, the foundation. JSON-RPC over socket/TCP/HTTP, or D-Bus; easy to drive from elisp. Mature, actively maintained, headless-first. The engine to build on.
- [[https://github.com/keenban/signel][signel]] — Emacs package. Drives signal-cli and parses its JSON; gives a conversation dashboard plus chat and send/receive. The only Emacs-native package doing the full loop. Lightly maintained.
- [[https://github.com/bbernhard/signal-cli-rest-api][signal-cli-rest-api]] — REST/WebSocket wrapper around signal-cli (Docker). Clean HTTP surface, but adds a Docker dependency.
- [[https://github.com/mrkrd/signal-msg][signal-msg]] — Emacs package, send-only via signal-cli. Trivial but no receive.
- [[https://github.com/whisperfish/presage][presage]] — Rust library, not turnkey; too much glue for this.
- [[https://github.com/foxl-ai/signal-cli][foxl-ai/signal-cli]] — newer Rust CLI on libsignal plus an MCP server; promising but v0.1.1 (March 2026) and unproven.
Recommendation: evaluate signal-cli as the engine (mature, headless, scriptable from elisp) with signel as the ready-made Emacs front end on top of it.
** DOING [#B] Consolidate to EAT as the single terminal :terminal:eval:
Evaluate whether EAT can be the one terminal for all usage and, if it holds up, switch to it from vterm. Reference: [[file:docs/2026-05-25-emacs-terminal-comparison.org][docs/2026-05-25-emacs-terminal-comparison.org]] (vterm vs eat vs ghostel research).
Goal: a single terminal engine across every workflow, including covering what eshell is used for today.
Open question to settle first: eshell is a shell, EAT is a terminal emulator, so EAT can't literally replace eshell. EAT's eshell story is =eat-eshell-mode=, which makes eshell use EAT for terminal display (full-screen programs run inside eshell). So "use EAT for what eshell does" most likely means one terminal engine everywhere: EAT for standalone terminal buffers (replacing vterm) plus =eat-eshell-mode= as eshell's visual backend, keeping eshell as the shell. Confirm that reading versus dropping eshell entirely for EAT + zsh.
*** 2026-05-26 Tue @ 15:15:43 -0500 Direction confirmed; Claude Code in eat needs a caveat
Craig confirmed the consolidation: one terminal engine everywhere — eat for standalone terminal buffers (replacing vterm) plus =eat-eshell-mode= as eshell's visual backend, keeping eshell as the shell. Not dropping eshell for eat + zsh.
Researched whether Claude Code runs cleanly in eat (Craig runs it in his Emacs terminal). Verdict: mostly, with caveats. eat is the default backend for claude-code.el and renders the TUI with color and full key handling, but there is an eat-specific bug where Claude Code's input handling makes the buffer scroll-pop to the top on window-buffer changes and the input box can get stuck mid-buffer (recoverable, but it does not happen in vterm or ghostel), and eat runs about 1.5x slower than vterm on heavy streaming output. claude-code.el's own docs name ghostel as the most faithful Claude TUI renderer.
Recommendation: consolidate everyday terminals onto eat, but keep ghostel (or vterm) for the Claude Code workflow specifically — the scroll-pop / stuck-input bug and the slower heavy-stream handling are exactly what bites a long Claude session. Sources: [[https://github.com/cpoile/claudemacs][claudemacs]], [[https://github.com/stevemolitor/claude-code.el][claude-code.el]], [[https://codeberg.org/akib/emacs-eat][emacs-eat]].
Eval plan (from the research doc): install EAT alongside vterm, run the same workloads through both, decide. Test matrix: Claude Code TUI, lazygit, htop/btop, yazi, a heavy-output build, ssh to a remote, and eshell with =eat-eshell-mode=. Assess rendering fidelity, stability under heavy output, and Emacs-native line editing. Switch only if it covers every workflow without regression.
** TODO [#B] Headline indicators wrap to a second row :bug:org:
The org-tidy property dot (=·=) and the fold ellipsis (=org-ellipsis= " ▾") spill onto a second visual row on some headings, with trailing whitespace after the heading. Seen across the agenda/todo view (e.g. "Rework dev F-keys", "Module-by-module hardening", "GPTel Work").
Not a regression of the tag right-align work — that fix is intact. =cj/org-tag-right-margin= is 5, =org-tags-column= 0, org-tidy inline with =·=, both in =modules/org-config.el= and the running daemon.
Trigger: a heading that has a hidden =:PROPERTIES:= drawer (→ org-tidy =·=) AND is folded with subtree content (→ org-ellipsis " ▾") carries both markers. Headings with only one marker stay on-line; the pair overflows. =cj/org--tag-align-spec= right-aligns the tag to =(- right (+ tagwidth 5))=, reserving 5 columns, but when the heading text runs long the =:align-to= target falls left of where the text ends, so the space can't stretch and the tag + =·= + " ▾" push past the window edge and wrap.
Fix direction: account for the indicator width in the reservation, or skip the right-align when the heading text is too long to fit tag+indicators (fall back to inline tags), or reconsider the display-property approach for long headings. Confirm against a few frame widths — the wrap point is width-dependent.
** PROJECT [#B] Implement ai-kb :feature:ai:kb:
Build v1 of the AI knowledge base per [[file:docs/design/ai-kb.org][docs/design/ai-kb.org]] (Ready; six reviews incorporated, all decisions resolved 2026-05-24). Step 1 splits into 1a (the safe write path — minimum usable) and 1b (retrieval, maintenance, push), since =remember= depends on =index=+=lint= and the adapter depends on =remember=. Step 2 is the Emacs layer: a full org-roam profile on switch, the human-edit safety model (same write path as the agent), and the browsing surface. Step 3 and the LLM-Wiki layer are vNext. Children are ordered by build sequence; the server bootstrap is the prerequisite.
*** TODO [#B] ai-kb bare repo on cjennings.net :ai-kb:
Prerequisite, one-time server bootstrap (not doable by the local script): =sudo git init --bare /var/git/ai-kb.git= + chown on cjennings.net. Leave the github-mirror hook OFF — this repo is private. Required before every per-machine clone.
*** TODO [#B] ai-kb store + contract + seed :ai-kb:
Step 1a. Clone =git@cjennings.net:ai-kb.git= to =~/.local/share/ai-kb=. Author =AGENT_CONTRACT.org= (canonical repo-resident contract: node format, write protocol, operations, routing) and seed =index.org= + a README/index node with a generated =:ID:=. Node format per spec — a *required* one-line =:SUMMARY:= (the index/query read it straight, no inference/LLM), provenance (=:CREATED_BY:/:CONFIDENCE:/:VISIBILITY:/:SOURCE:/:STATUS:=), =:PROJECTS:= slugs, type filetags, relation labels. Define the durable external-pointer format as *ID-first*: =ai-kb:
()=, resolved by ID with title fallback (filenames can change in curation).
*** TODO [#B] ai-kb CLI 1a: index, lint, remember, doctor :ai-kb:
Step 1a. Shell wrapper calling Emacs for org work — =emacsclient= when a daemon is up, =emacs --batch= fallback, lint+index in *one* invocation per =remember=. =index= regenerates =index.org= from node properties incl. =:SUMMARY:= (never hand-maintained); the index references nodes as plain =Title (UUID)= text, never =[[id:]]= links, and is excluded from the scan so it can't manufacture backlinks or hide orphans. =lint= = org-lint fatal checks + duplicate IDs + broken id-links (excl =raw/= + index) + missing required props (incl =:SUMMARY:=) + bad project slugs + stale/incomplete index + credential scan of nodes *and* =raw/= text files (binaries skipped). =remember= = the write protocol: fetch + =pull --ff-only= (abort on diverge/dirty), write, regenerate index, then run the *full =ai-kb lint=* over the change as the commit gate (not just node org-lint — this is the safety boundary), commit locally, =flock=; no push. =doctor= / =status= = health + push-state + raw-dir-size report (repo, private remote, CLI on PATH, =graphviz= if the map needs it, adapter linked, db buildable, no secrets, "ahead N"/"push failed"/"diverged"); =status= is the fast non-diagnostic mode for the dashboard/nudge.
*** TODO [#B] claude-rules/ai-kb.md adapter :ai-kb:
Step 1a. Global L1 rule in rulesets pointing at the repo-resident =AGENT_CONTRACT.org=: path, routing (T1/T2/T3 tiers; per-project =MEMORY.md= shrinks to ID-first pointers into ai-kb), proactive + contradiction rules, concrete "read the index first" triggers, link-grep recipes, "use =ai-kb remember=, never bypass =ai-kb lint=", one-line nudge on unpushed commits / recorded push rejection. =make install= symlinks it into =~/.claude/rules/=.
*** TODO [#B] ai-kb provisioning: setup-ai-kb.sh + make ai-kb-init :ai-kb:
Step 1a (core; the timer-install line is added with 1b). Idempotent =scripts/setup-ai-kb.sh=: clone (or init+add-remote on first machine), seed, install the CLI on PATH, =ai-kb index=, =ai-kb doctor=. =make ai-kb-init= wraps it. The one-time server bootstrap stays a separate documented step.
*** TODO [#B] ai-kb Step-1a tests :ai-kb:tests:
Write-path: a write with the remote unreachable still commits locally and does not error; =flock= serializes concurrent =remember=; each org-lint *fatal* check (malformed drawer, missing/dup =:ID:=, invalid required property, missing =#+title:=, unparseable org) rejects the commit, a style warning does not; a node missing =:SUMMARY:= fails lint; =remember= aborts the commit when the *full* lint fails (stale index, broken link, secret in a node or =raw/= text file); the credential scan skips binaries. Index: regen from a fixture produces expected entries; an out-of-band node appears only after regen; a node referenced only by =index.org= still reports as an orphan (the index is not a backlink source). Link recipes: backlink (excl =raw/= + index) + forward correct. Provisioning (bats): idempotent, valid =:ID:= + =:SUMMARY:=, =doctor= passes.
*** TODO [#B] ai-kb CLI 1b: query, curate, sync :ai-kb:
Step 1b. =query = with a *testable contract*: plain-text default + =--json=; fields title/ID/summary/projects/status/updated/path + *match reason*; searches index rows + title/tags/properties/body; ranks by lexical score — sum of each matched field's weight, counted once per field: title 100, tag/project/status 50 each, summary 20, body 5; no term-frequency weighting in v1 — with most-recently-updated (=:UPDATED:=) only as the *tie-break* on equal scores (recency alone buries stable old preferences); default max-results; =raw/= paths only as source references; exit codes for no-match / invalid KB / lint-index failure. =show = (resolve ID-first, print the node) and =backlinks = (excl =raw/= + index) as the inspection primitives the Emacs commands wrap. =curate --dry-run= (four buckets; also flags orphan =raw/= captures and any =raw/= file over 256 KB; destructive ops human-only). =sync= (=org-roam-db-sync= against ai-kb) only when the db is missing/stale or forced.
*** TODO [#B] ai-kb push timer + failure observability :ai-kb:
Step 1b. =ai-kb-push.timer= + =ai-kb-push.service= =systemd --user= units: push only if ahead, ~15 min; installed + =enable --now= by the setup script (add this line to =setup-ai-kb.sh=). A failed push is logged to a state file (=$XDG_STATE_HOME/ai-kb=), never fatal; surfaced by =ai-kb doctor= and the adapter's startup nudge.
*** TODO [#B] ai-kb-curate workflow in rulesets :ai-kb:
Step 1b. =~/code/rulesets/.ai/workflows/ai-kb-curate.org= — human-gated curation: the four buckets, node-count trigger (nudge at 150 nodes, re-fire every +50), =:LAST_CURATED:= rotation, pointer-integrity (merge/supersede changes the canonical ID, so grep inbound =[[id:]]= + =MEMORY.md= =ai-kb: ... (UUID)= refs and repoint before deleting). Surfaced by =ai-kb doctor= + session startup when due.
*** TODO [#B] ai-kb Step-1b tests :ai-kb:tests:
=query --json= returns the specified fields (incl. match reason)/exit-codes on a fixture KB and =raw/= appears only as a source ref; 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); a simulated push failure is recorded to the state file and surfaced by =ai-kb doctor= / =status=. Performance (=:perf= tag): 100- and 1,000-node fixtures keep =index=/=query=/=lint=/=remember= under a stated time budget (catches an accidental per-check Emacs startup or an O(n²) scan).
*** TODO [#B] Emacs: org-roam ai-kb profile + switch :ai-kb:
Step 2.
=org-roam-config.el=: =cj/org-roam-switch-to-ai-kb= / =cj/org-roam-switch-to-personal= install a full org-roam *profile*, not a two-variable swap — dir + =org-roam-ai.db= + =org-roam-file-exclude-regexp= (=raw/= + =index*.org=), and dailies, capture templates, topic/project/recipe find wrappers, and the agenda/refile + completed-task→daily hooks all rescoped or neutralized so ai-kb nodes never leak into personal journals/agenda. Restore everything exactly on exit; re-assert personal state at startup (abnormal-exit safety). =cj/ai-kb-db-sync= syncs only when the db is missing/stale or forced, with a status indicator.
*** TODO [#B] Emacs: ai-kb edit safety (same write path) :ai-kb:
Step 2. An =ai-kb= minor mode whose =after-save-hook= runs the agent's post-write sequence under =flock= — =ai-kb index=, full =ai-kb lint=, commit, push-state update — so a human Emacs edit can't bypass index/lint/commit. One write path for both agent and human. Failure UX: the save always writes to disk and the buffer stays editable (never read-only/blocked); on lint failure it does *not* commit, pops findings to a =*ai-kb-lint*= buffer (no focus steal), and shows the uncommitted-failing state in the modeline + dashboard — Craig fixes and re-saves, a clean save commits. Recursion guard, two layers: the mode's activation predicate excludes =index*.org= + =raw/=, and the pipeline binds a re-entrancy flag (=cj/ai-kb--in-pipeline=) the hook early-returns on; index regen prefers =write-region= over =save-buffer=.
*** TODO [#B] Emacs: ai-kb browsing surface :ai-kb:
Step 2. =cj/ai-kb-dashboard= (status banner: active KB, node count, unpushed commits, push-failure state, curation due, last index/sync), =cj/ai-kb-find-node= (=org-roam-node-find= in the ai-kb profile), =cj/ai-kb-search= (=ai-kb query= or scoped =consult-ripgrep=), =cj/ai-kb-show-node= (resolve ID-first, open), =cj/ai-kb-backlinks= (excl =raw/= + index), =cj/ai-kb-map= (built-in =org-roam-graph= *first* — the profile's exclude regexp already keeps =raw/= + index out of the db, so the graph inherits the right scope; custom DOT export only if project/tag/status filtering proves necessary; =graphviz= dep). Simple wrappers over the CLI primitives where possible.
*** TODO [#B] Emacs: ai-kb keybindings + which-key :ai-kb:
Bind the switch + sync + browsing commands under the =C-c n= roam prefix (e.g. =C-c n a= → ai-kb, =C-c n A= → personal, a small transient for the browsing commands), avoiding the dense existing set; which-key labels.
*** TODO [#B] Emacs: ai-kb Step-2 ERT tests :ai-kb:tests:
Profile: switch installs the ai-kb dir + db + exclude regexp and switch-back restores personal *exactly* — completed-task hook, agenda/refile finalize hook, dailies, and capture templates all untouched by ai-kb while switched; startup re-asserts personal state after a simulated abnormal exit. Edit path: a save in an ai-kb buffer runs index+lint+commit (a bad save surfaces the lint failure rather than committing). Sync runs only when stale.
** PROJECT [#B] Architecture review follow-up from 2026-05-03 :refactor:nosync:
High-level pass over =init.el=, =early-init.el=, and all 104 files in
=modules/=. The main theme: the config works, but load order, startup side
effects, credentials, and test measurement are more implicit than they should
be. Use this project as the parent tracker; each child below should land as a
small, reviewable change.
Review snapshot:
- =modules/= has 104 files and about 24k lines including =init.el= and
=early-init.el=.
- =init.el= eagerly =require=s nearly every module.
- =make coverage= passed when allowed to write the test scratch directory.
- Coverage report: =3240/4952= executable lines, =65.43%=, across 49 module
files. Caveat: 55 module files do not appear in the report at all, so the
real project confidence is lower than the raw percentage suggests.
*** 2026-05-15 Fri Consolidate shared utility helpers :architecture:refactor:
CLOSED: [2026-05-15 Fri]
Helpers are scattered across feature modules where they were first needed.
Some are duplicated, and some private helpers are generic enough to belong in a
shared foundation library. This is adjacent to the load-graph refactor because
central helper ownership reduces hidden inter-module dependencies, but it
should remain a sibling project so load-order batches stay small and
reviewable.
Guidance:
- Do not extract a helper until at least two callers are clearly the same
shape.
- Prefer growing =system-lib.el= first; split into topic libraries only if it
becomes too broad or starts pulling coarse dependencies into foundation
startup.
- Keep one helper extraction per commit.
- Move unit tests with the helper. Consumers should keep behavior/integration
coverage.
- Do not add heavy package dependencies to foundation helpers.
**** DONE [#B] Write full utility consolidation design spec :architecture:refactor:
CLOSED: [2026-05-04 Mon]
Create a design document that inventories candidate helper extractions,
recommends grouping and naming, explains how the helpers fit into existing
library modules, defines migration phases, and identifies testing/rollback
rules.
Spec: [[file:docs/design/utility-consolidation.org][docs/design/utility-consolidation.org]]
Verify 2026-05-04:
- Added [[file:docs/design/utility-consolidation.org][docs/design/utility-consolidation.org]].
- Spec includes framing questions, existing library fit, proposed grouping,
concrete pull/rename table, migration phases, test strategy, acceptance
criteria, risks, open questions, and recommended first commits.
- Parsed the spec and =todo.org= with =org-element=.
- Committed the tracked spec as =3ea4707=.
- Incorporated complete review feedback in =dd77ebd=, including API behavior
contracts, speculative-extraction rules, =system-lib= dependency budget,
inventory/audit artifacts, test relocation policy, commit type guidance,
=use-package :if= load-order policy, and Phase 5 cache-design addendum
requirement.
**** DONE [#B] Inventory private helpers across modules :refactor:
CLOSED: [2026-05-10 Sun]
Walk every module and tag private helpers as genuinely module-specific,
generic-but-trapped, or duplicated. Capture likely consumers and any dependency
cost before extracting.
Candidate families:
- shell argument formatting,
- executable lookup with user-visible warnings,
- argv-based process runners,
- path containment/safe-base predicates,
- Org-safe heading/property/body text sanitizers,
- cache-with-TTL plus invalidation hooks,
- warning/message wrappers.
Verify 2026-05-10:
- Added [[file:docs/design/utility-inventory.org][docs/design/utility-inventory.org]] covering the 30 entries in the spec's
Candidate Extraction Table grouped by family (executable discovery, shell
quoting, process runner, file/path, external-open, Org-safe text, cache,
logging, macros/debug, theme I/O, string).
- For each helper recorded: visibility, dependencies, side effects, callers
(production + test), test files, priority, decision (Migrate / Leave / Defer)
with rationale.
- Decisions Summary: 11 Migrate, 3 Leave, 13 Defer.
- Concrete next-action list groups Migrate items by Phase (2 = foundation
helpers, 3 = Org-safe text, 4 = external-open consolidation) for the order
the spec recommends.
- Discoveries: =cj/log-silently= has 10 production callers (more than the
spec's table suggested -- defer is the right call); =cj/--file-manager-program-for=
shipped today in =dirvish-config.el= is the new form of OS-dispatch
consolidation and should fold into =cj/external-open-command= during Phase 4.
**** DONE [#B] Extract executable lookup with warning helper :refactor:
CLOSED: [2026-05-10 Sun]
Create a generic helper such as =cj/find-executable-or-warn= from the useful
=mail-config= pattern. It should return the executable path or nil and produce
a clear warning when the executable is missing.
Done 2026-05-10:
- Shipped as =cj/executable-find-or-warn= in =modules/system-lib.el=
(commit =c75e36f4=, extracted from =mail-config=).
- First consumer rewired in =12c2cb14= (=cj/set-wallpaper= in
=dirvish-config.el=).
**** DONE [#B] Extract argv-based process runner helper :refactor:
CLOSED: [2026-05-10 Sun]
Generalize the =coverage-core= process pattern into a dependency-light helper
that captures output and signals a clear =user-error= with command/status/output
on failure. Consider a small git wrapper only after the generic runner exists.
Done 2026-05-10:
- Shipped =cj/process-output-or-error= plus the =cj/git-output-or-error=
wrapper in =modules/system-lib.el= (commit =57e558ce=, extracted from
=coverage-core=).
**** DONE [#B] Extract Org-safe text sanitizers :refactor:
CLOSED: [2026-05-10 Sun]
Move heading/property/body sanitization into a shared helper once at least one
non-calendar consumer is ready. Keep behavior explicit so external text cannot
accidentally create headings or malformed properties.
Done 2026-05-10:
- Shipped =modules/cj-org-text-lib.el= (renamed to its final =-lib= form in
commit =0f9e3087=) with three sanitizers: =cj/org-sanitize-body-text=,
=cj/org-sanitize-property-value=, =cj/org-sanitize-heading=.
*** 2026-05-15 Fri Make coverage reporting account for untracked modules :tests:
CLOSED: [2026-05-15 Fri]
The current coverage result is useful but easy to overread. =make coverage=
reported =65.43%= for files that undercover saw, but only 49 of 104 module
files appeared in =.coverage/simplecov.json=.
Definition: in this task, "untracked modules" means repository-owned
=modules/*.el= files that should be part of the Emacs configuration coverage
universe but have no entry in =.coverage/simplecov.json= after =make coverage=
runs. These files may be missing because no test required them, because loading
was skipped due to package/environment guards, or because instrumentation did
not see them. They are distinct from tracked modules with 0% covered lines,
which already appear in SimpleCov and can be scored directly.
Completed 2026-05-15:
- Both child tasks are done.
- =make coverage-summary= reports missing modules explicitly and also reports a
separate project-module score where missing modules count as 0%.
- Focused summary tests and byte-compilation of the summary helper passed.
**** 2026-05-15 Fri Teach the coverage report to list modules missing from SimpleCov
CLOSED: [2026-05-15 Fri]
Expected outcome:
- Compare =modules/*.el= against paths present in =.coverage/simplecov.json=.
- Show a separate "not in report" section.
- Do not silently fold those files into the percentage until we decide the
semantics. A visible missing-file count is enough for v1.
Done 2026-05-15:
- =make coverage-summary= now compares direct =modules/*.el= files on disk
against the module paths present in =.coverage/simplecov.json=.
- The terminal report appends a =Not in SimpleCov report= section with a count
and the missing module paths.
- Missing modules are explicitly excluded from the displayed percentage for
now; the policy question below remains open.
- Added focused tests in =tests/test-coverage-summary.el= for missing-module
reporting and for ignoring =.elc= files and nested paths outside direct
=modules/*.el= ownership.
**** 2026-05-15 Fri Decide whether unreported modules count as 0% coverage
CLOSED: [2026-05-15 Fri]
This is a policy decision:
- Counting missing modules as 0% gives a more honest project-level number.
- Keeping the current number is useful for "instrumented executable lines only".
Recommendation: display both:
- Instrumented coverage: current SimpleCov percentage.
- Project module coverage: includes unreported module files as 0% or reports
them separately with an explicit caveat.
Decision 2026-05-15:
- Keep the existing SimpleCov percentage as the line-weighted
=instrumented coverage= number. It only covers modules that SimpleCov saw and
has real executable-line denominators for.
- Also display a separate module-weighted =project module coverage= score over
all direct =modules/*.el= files. Modules present in SimpleCov contribute their
per-file coverage percentage; modules absent from SimpleCov count as 0%.
- Do not pretend missing modules have known executable-line counts. Counting
them as 0% at the module level is honest about risk without inventing a line
denominator.
Done 2026-05-15:
- =make coverage-summary= now prints both the existing line-weighted summary
and a separate =Project module coverage= line that includes missing modules
as 0%.
- The missing-module section now states that missing modules count as 0% in the
project-module score.
- Updated =tests/test-coverage-summary.el= to assert the policy and the
displayed project-module percentage.
*** 2026-05-15 Fri Add a lightweight architecture smoke test for startup contracts :tests:
CLOSED: [2026-05-15 Fri]
After the above refactors start, add one or two smoke tests that protect the
architecture instead of individual functions.
Candidate checks:
- All modules can be loaded directly with only =modules/= on =load-path=, or
skipped with a clear external package reason.
- No module other than =keybindings.el= binds =C-;= itself.
- Startup-only modules do not run timers in batch test mode.
Keep this small. The goal is to catch accidental return to hidden load-order
coupling, not to build a full static analyzer.
Done 2026-05-15:
- Added =tests/test-architecture-startup-contracts.el= with two source-level
smoke checks:
- only =keybindings.el= may globally own the exact =C-;= prefix;
- top-level timer scheduling forms must be guarded by =noninteractive= so
batch/test loads do not schedule startup timers.
- Gated existing startup timers in =org-agenda-config.el=,
=org-refile-config.el=, =quick-video-capture.el=, and =wrap-up.el=.
- Focused tests passed for the new architecture smoke file and the affected
agenda/refile helpers.
*** PROJECT [#A] Un tangle the eager =init.el= load graph :architecture:refactor:
=init.el= currently functions as the dependency graph by eagerly requiring
almost every module in a fixed order. That makes modules harder to test in
isolation and hides real dependencies behind "loaded earlier in init.el"
assumptions.
Spec: [[file:docs/design/init-load-graph.org][docs/design/init-load-graph.org]]
**** 2026-05-25 Mon @ 07:59:20 -0500 Wrote full design spec for the =init.el= load-graph refactor :architecture:refactor:
Create a design document that defines the target architecture, module
categories, migration phases, test strategy, acceptance criteria, and risk
controls for untangling the eager =init.el= load graph.
Review incorporation:
- Treat helper consolidation as adjacent architecture work, not a direct
acceptance criterion for the load-graph refactor.
- Mention utility extraction guardrails in the spec so Phase 2 dependency work
has a clear rule for duplicated helpers found along the way.
Verify 2026-05-04:
- Added [[file:docs/design/init-load-graph.org][docs/design/init-load-graph.org]].
- Incorporated review feedback by making utility consolidation an explicit
sibling project with guardrails and candidate helper families.
- Parsed the spec and =todo.org= with =org-element=.
- Committed the tracked spec as =0528475=.
**** 2026-05-24 Sun @ 17:07:03 -0500 Classified modules by role and startup requirement
Built [[file:docs/design/module-inventory.org][docs/design/module-inventory.org]] across 9 batches: 101 of 102 init.el-required modules annotated with the load-graph header contract (Layer, Category, Load shape, Eager reason, Top-level side effects, Runtime requires, Direct test load) and tabulated in the inventory. Added =tests/test-init-module-headers.el= to enforce the contract on each classified module. Retired the three vague =init.el= comments (latex-config WIP, prog-shell "combine elsewhere", "Modules In Test" banner) into real tasks. Recorded seven hidden =cj/custom-keymap= / cross-module dependencies for the Phase 2 dependency pass. Tagged the span =load-graph-classify-start..load-graph-classify-end=. elfeed-config is the one module left, pulled to its own task below.
**** 2026-05-25 Mon @ 08:35:33 -0500 Annotated elfeed-config load-graph header
Added the load-graph header to elfeed-config (Layer 4, O/D/P, current load shape eager with an eager reason, target command-loaded; runtime requires user-constants, system-lib, media-utils), added it to the header-contract allowlist in =tests/test-init-module-headers.el= (Batch 8), and moved it in =docs/design/module-inventory.org= from the Deferred/Pending sections into the Batch 8 table. Inventory now 102 of 102 classified. The header's "Load shape" records the current shape (eager, required in init.el) per the weather-config/games-config convention; "command-loaded" is the target, in the inventory's Target column. Shipped as a522e553.
**** 2026-05-24 Sun @ 18:35:06 -0500 Made hidden module dependencies explicit
Fixed the seven hidden dependencies the classification surfaced: system-defaults now requires host-environment and user-constants at runtime (was eval-when-compile); custom-buffer-file, dev-fkeys, calendar-sync, and video-audio-recording require keybindings and drop their =(when (boundp 'cj/custom-keymap) ...)= shims; flycheck-config and mail-config require keybindings for their cj/custom-keymap bindings. Removed a dead =eval-when-compile (defvar cj/custom-keymap)= in transcription-config (the var was never used).
No init.el load-order change — keybindings and the foundation modules already load before these, so the explicit requires are no-ops at startup and only fix standalone/test loading.
Verified each fix with a fresh =emacs --batch (require 'X)=, then swept all ~100 modules standalone: every one loads or fails only with a clear missing-package message (the spec's Phase 2 exit bar). Full =make test=, =make validate-modules=, and an init smoke all pass. Module headers and the inventory's hidden-dependency section updated to mark the seven resolved.
**** TODO [#B] Defer feature modules behind autoloads, hooks, and commands :refactor:
Once dependencies are explicit, reduce the number of modules required at
startup. Start with lower-risk feature modules:
- Entertainment and optional integrations: =games-config=, =music-config=,
=weather-config=, =slack-config=, =erc-config=.
- Heavy document/media modules: =pdf-config=, =calibredb-epub-config=,
=video-audio-recording=, =transcription-config=.
- AI/rest tooling: =ai-config=, =restclient-config=, =ai-conversations=.
Do this incrementally. After each batch:
- Restart Emacs interactively.
- Run =make test= or at least targeted tests.
- Check that keybindings still resolve and which-key labels still appear.
**** 2026-05-24 Sun @ 19:59:01 -0500 Centralized custom keymap registration
Added cj/register-prefix-map and cj/register-command to keybindings.el (commit 47f222f6) with test-init-keymap-registration.el, then migrated all 31 cj/custom-keymap registration sites across 24 modules onto the API. Consumers no longer reference cj/custom-keymap directly — keybindings.el is the sole owner of the prefix, and modules require keybindings to reach the API.
Verified behavior-preserving by dumping every C-; binding before and after: identical, 279 bindings, each resolving to the same command. Byte-compiled all 24 migrated files (no new free-variable warnings — the cj/custom-keymap coupling is gone), and full make test, validate-modules, and an init load all pass. which-key label blocks were left intact; they use string key descriptions and never assumed cj/custom-keymap existed.
Related existing task: [#B] "Review and rebind M-S- keybindings".
*** PROJECT [#A] Move package bootstrap out of =early-init.el= where possible :startup:refactor:
=early-init.el= currently handles package archives, package refresh, installing
=use-package=, and =use-package-always-ensure=. That is more than early startup
needs and can make startup network-sensitive.
**** TODO [#B] Split early startup from package bootstrap :refactor:
Keep =early-init.el= focused on things that must happen before package and UI
startup:
- GC/file-name-handler startup tuning.
- =load-prefer-newer=.
- frame/UI suppression.
- minimal debug behavior.
Move package archive setup and =use-package= installation to a normal module or
bootstrap command, unless there is a specific reason it must run in
=early-init.el=.
Acceptance criteria:
- Fresh install/bootstrap still works from a documented command or script.
- Normal startup does not refresh archives or install packages unexpectedly.
- Offline startup remains quiet and predictable.
**** TODO [#A] Revisit package signature policy
=package-check-signature= is disabled. Decide whether that is still necessary
for the localrepo/mirror workflow.
Expected outcome:
- Prefer signatures on by default.
- If signatures must be disabled for local mirrors, scope that exception and
document why.
- Add a note to the local repository docs so future package failures do not
lead to permanent insecure defaults.
** TODO [#B] Rework dev F-keys: compile+run (F4), test (F6), coverage (F7) :feature:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-22
:END:
*** TODO [#B] Format keybindings move off F6 :refactor:cleanup:
Move blacken-buffer (python), shfmt-buffer (sh), and clang-format-buffer (c)
off F6 onto the =C-; f= prefix, which already hosts format-buffer bindings.
Also remove projectile-run-project from F6 (it folds into the new F4).
Touch the per-language config modules that currently bind F6 for formatting.
Acceptance: F6 has no remaining format-or-run bindings in any module; =C-; f=
prefix triggers the right formatter per major mode.
Depends on: none (start here -- clears F6 before F4/F6 work lands).
*** TODO [#B] Project-type detection helper :feature:
Single helper that returns a project-type symbol (=compiled=, =interpreted=,
=unknown=) from the current buffer's project. Uses
=projectile-project-compilation-cmd= when set, then heuristic fallbacks:
=go.mod=, =Makefile=, =Eask=, =package.json=, =pyproject.toml=,
=docker-compose.yml=. Lives near the F4 dispatcher.
Acceptance: ERT tests cover each heuristic in isolation plus a precedence
case where projectile's cached cmd wins over the file heuristics.
Depends on: none.
*** TODO [#B] F4 compile+run dispatcher :feature:
Build the F4 binding per spec: plain F4 opens a completing-read whose
candidates depend on project-type (Compile / Run / Compile + Run [default] /
Clean + Rebuild for compiled; Run only for interpreted). C-F4 fast-paths to
Compile; M-F4 fast-paths to Clean + Rebuild. Both fast paths show a "not a
compiled language" message and no-op on interpreted projects. Reads
projectile's per-project compile/run commands; no Docker-specific logic.
Acceptance: each candidate dispatches to the right projectile command; fast
paths no-op cleanly on interpreted projects; F4 bindings live in one module.
Depends on: project-type detection helper.
*** TODO [#B] Per-language test discovery :feature:tests:
Provide a single =cj/--tests-in-buffer= function returning a list of test
names for the current buffer's language. Tree-sitter queries for Python,
Go, TS/JS (treesit-auto already configured); built-in sexp scan for elisp
(=ert-deftest= forms). Parsing unopened test files uses with-temp-buffer +
insert-file-contents + -ts-mode + treesit-query-capture. Queries are
spelled out in the spec above.
Acceptance: ERT tests feed each language a fixture file and assert the
expected test-name list comes back; missing grammar surfaces a clear error.
Depends on: none (parallel-safe with F4 work).
*** TODO [#B] F6 test dispatcher :feature:tests:
Build the F6 binding per spec: plain F6 opens completing-read with "All
tests", "Current file's tests", "Run a test..."; C-F6 fast-paths to current
file's tests; M-F6 fast-paths to "Run a test...". "Current file's tests"
runs the buffer directly if it's a test file, otherwise finds matching test
files via language conventions (elisp =tests/test-*.el=, python
=tests/test_.py=, etc.) and runs them aggregated. "Run a test..."
pre-selects =cj/--last-test-run= (buffer-local) and errors with "No tests
found for " when discovery returns nothing -- no silent fallthrough.
Acceptance: each entry point dispatches to the right runner; buffer-local
last-test memory persists per source file; no-match error fires correctly.
Depends on: per-language test discovery.
*** TODO [#B] F7 hand-off to dev-fkeys story :feature:
Once the coverage track ships ([[file:../docs/design/coverage.org][docs/design/coverage.org]]),
confirm F7 binds =cj/coverage-report= and lives alongside F4/F6 in the same
dev-fkeys module so the three keys read as one unit. No new coverage logic
here -- only the binding placement and a short comment block in the module
pointing at the coverage design doc.
Acceptance: F7 invokes coverage-report; F4/F6/F7 are visibly grouped in one
module; coverage track is shipped before this lands.
Depends on: the coverage-config track shipping; F4 and F6 sub-tasks above.
*** 2026-05-15 Fri @ 19:16:08 -0500 Specification
Consolidate the developer F-key block into a coherent sequence. F5 reserved for debug (separate ticket). Format bindings move off F6 to C-; f.
Menu mechanism: =completing-read= everywhere (consistent with F7 coverage scope prompt and with the vertico/consult workflow in the rest of the config). No transient definitions.
**F4 — compile + run**
- F4 (no modifier): completing-read with candidates filtered by project type. Detection via projectile-project-compilation-cmd and heuristic fallbacks (go.mod, Makefile, Eask, package.json, pyproject.toml, docker-compose.yml).
- Compiled project candidates: "Compile", "Run", "Compile + Run" (default), "Clean + Rebuild"
- Interpreted project candidates: "Run" only
- C-F4: fast path = Compile only. On interpreted projects, shows "not a compiled language" and no-ops.
- M-F4: fast path = Clean + Rebuild. Same "not applicable" behavior on interpreted projects.
The dispatcher reads projectile's per-project compile/run/test commands. No Docker-specific logic in the command itself. Container workflows are configured via projectile's prompt-and-cache (or .dir-locals.el from the dev-project-setup helper).
**F6 — run tests**
- F6 (no modifier): completing-read top-level:
- "All tests"
- "Current file's tests"
- "Run a test..." (nested completing-read with individual tests)
- C-F6: fast path = "Current file's tests"
- M-F6: fast path = "Run a test..."
"Current file's tests": if current buffer is a test file, run it directly. If source file, find matching test file(s) via language conventions (elisp: tests/test-*.el; python: tests/test_.py; etc.) and run them aggregated.
"Run a test...": build a candidate list of individual tests, pre-select the last-chosen test for this buffer (buffer-local cj/--last-test-run), present via completing-read. Pressing RET re-runs last. Memory is buffer-local so different source files remember their own last-test.
Candidate set for "Run a test...":
- If buffer is a test file: parse the file, return its test definitions.
- If buffer is a source file: find matching test file(s) and aggregate their test definitions.
- No matches: error out with "No tests found for ". Don't silently fall through.
Per-language test discovery:
- Python, Go, TypeScript/JavaScript: tree-sitter queries (treesit-auto already configured, grammars auto-install)
- Python: (function_definition name: (identifier) @name (:match "^test_" @name))
- Go: (function_declaration name: (identifier) @name (:match "^Test" @name))
- TS/JS: (call_expression function: (identifier) @fn arguments: (arguments (string) @name) (:match "^\\(test\\|it\\)$" @fn))
- Parsing unopened test files: use with-temp-buffer + insert-file-contents + python-ts-mode (etc.) + treesit-query-capture
- Elisp: built-in sexp navigation; scan for (ert-deftest ...) forms. No tree-sitter needed.
*F7 — coverage* (already designed in docs/design/coverage.org)
**Required moves:**
- Move blacken-buffer (python), shfmt-buffer (sh), clang-format-buffer (c) off F6 to C-; f prefix (already the format-buffer prefix).
- Move projectile-run-project off F6 (folds into the new F4 completing-read).
**Ordering:**
Do this after the coverage-config work ships. No churn mid-flight.
** TODO [#B] Fix up test runner :bug:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-22
:END:
*** 2026-05-16 Sat @ 11:15:51 -0500 Ideas
**** Current State
=modules/test-runner.el= is a solid first pass for an Emacs-config-specific ERT
workflow:
- project-scoped focus lists
- run all vs focused mode
- run ERT test at point
- load all test files
- clear ERT tests from other project roots
- keybindings under =C-; t=
The universal test-running direction is currently split across modules:
- =test-runner.el= owns ERT focus/state/UI.
- =dev-fkeys.el= owns F6 language detection and command generation for Elisp,
Python, Go, and partial TypeScript.
That split is the biggest architectural pressure point. The test runner should
eventually own runner discovery, scopes, command construction, result handling,
and UI. F6 should become a thin entry point into the runner.
**** Critical Design Issues
***** Too ERT-specific at the core
The current state model is named generically, but most operations assume:
- test files live in =test/= or =tests/=
- files match =test-*.el=
- tests are ERT forms
- individual tests can be selected by ERT selector regex
- loading tests into the current Emacs process is acceptable
This makes the module hard to extend cleanly to pytest, Jest, Vitest, Go, Rust,
or shell test runners. The common abstraction should be "test run request" and
"test runner adapter", not "ERT file list".
***** In-process ERT causes state contamination
=cj/test-load-all= and focused runs load test files into the current Emacs
session. This is fast and ergonomic, but it can leak:
- global variables
- advice
- loaded features
- overridden functions
- ERT test definitions
- load-path mutations
The runner should support two ERT execution modes:
- =interactive= / in-process for fast local TDD
- =isolated= / batch Emacs for reliable verification
The isolated path should be preferred for "before commit", CI parity, and
agent-driven verification.
***** Test discovery is regex-based and fragile
=cj/test--extract-test-names= scans files with a regex for =ert-deftest=.
That misses or mishandles:
- macro-generated tests
- commented forms in unusual shapes
- multiline or reader-conditional forms
- non-ERT Elisp tests such as Buttercup
- stale ERT tests already loaded in the session
Better approach:
- for ERT in isolated mode, let ERT discover tests after loading files
- for source navigation, use syntax-aware forms where possible
- store discovered tests as structured records with file, line, name, framework,
tags, and runner
***** Path containment has at least one suspicious edge
=cj/test--do-focus-add-file= checks:
#+begin_src elisp
(string-prefix-p (file-truename testdir) (file-truename filepath))
#+end_src
That should use =cj/test--file-in-directory-p= or ensure the directory has a
trailing slash. Otherwise sibling paths with a shared prefix are a recurring
class of bug.
***** Runner commands are shell strings too early
=cj/--f6-test-runner-cmd-for= returns shell command strings. That makes it
harder to:
- inspect command parts
- safely quote arguments
- offer command editing
- run via =make-process= / =compilation-start= without shell ambiguity
- attach metadata
- rerun exact invocations
- convert commands into UI labels
Prefer a structured command object:
#+begin_src elisp
(:program "pytest"
:args ("tests/test_foo.py" "-q")
:default-directory "/project/"
:env (("PYTHONPATH" . "..."))
:runner pytest
:scope file)
#+end_src
Render to a shell string only at the final compilation boundary.
***** F6 and =C-; t= workflows duplicate the same domain
F6 already handles "all tests" and "current file's tests" for multiple
languages. =C-; t= handles ERT-only focus and run state. These should converge
on one runner service:
- F6: quick entry point
- =C-; t=: full runner menu
- both call the same scope/adapter engine
***** Test directory discovery is too narrow
Current discovery prefers =test/= then =tests/=, with a global fallback. Real
projects often need:
- Python: =tests/=, package-local =test_*.py=, =pytest.ini=, =pyproject.toml=
- JS/TS: =package.json= scripts, =vitest.config.*=, =jest.config.*=,
=*.test.ts=, =*.spec.ts=
- Go: package directories, =go.mod=
- Rust: =Cargo.toml=, integration tests under =tests/=
- Elisp packages: =Makefile=, =Eask=, =ert-runner=, Buttercup, =tests/=
Discovery should be adapter-specific and project-config-aware.
***** No structured result model
=cj/test-last-results= exists but is not meaningfully populated. A powerful
runner needs a normalized result model:
- run id
- started/finished timestamps
- status: passed/failed/errored/cancelled/skipped/xfail/xpass
- command
- runner adapter
- scope
- exit code
- duration
- failed test records
- file/line locations
- raw output buffer
- coverage artifact paths
This enables last-failed, failures-first, summaries, dashboards, and AI-assisted
failure explanation.
***** No failure parser / navigation layer
Compilation buffers are useful, but the runner should parse common failure
formats and provide:
- next/previous failure
- jump to source line
- failure summary buffer
- copy failure context
- rerun failed test at point
- annotate failing tests in source buffers
Adapters can provide regexes/parsers for ERT, pytest, Jest/Vitest, Go, Rust,
and shell.
***** Missing watch/rerun modes
Modern test runners optimize the feedback loop:
- pytest supports selecting tests, markers, last-failed, failures-first,
stepwise, fixtures, xfail/skip, plugins, and cache state.
- Jest/Vitest support watch workflows, changed-file selection, coverage,
snapshots, and rich interactive filtering. Vitest also defaults to watch in
development and run mode in CI.
- Go and Rust runners commonly support package-level runs, regex selection,
race/coverage flags, and cached test behavior.
The Emacs runner should expose the subset that maps well to editor workflows:
- current test
- current file
- related test file
- focused set
- last failed
- failed first
- changed since git base
- watch current scope
- full project
- coverage for current scope
**** Proposed Architecture
***** Core Types
Use plain plists initially; promote to =cl-defstruct= only if helpful.
#+begin_src elisp
;; Test runner adapter
(:id pytest
:name "pytest"
:languages (python)
:detect cj/test-pytest-detect
:discover cj/test-pytest-discover
:build-command cj/test-pytest-build-command
:parse-results cj/test-pytest-parse-results
:capabilities (:current-test :file :project :last-failed :coverage :watch))
;; Test run request
(:project-root "/repo/"
:language python
:framework pytest
:scope file
:file "/repo/tests/test_api.py"
:test-name "test_create_user"
:extra-args ("-q")
:profile default)
;; Test run result
(:run-id "..."
:status failed
:exit-code 1
:duration 2.14
:failures (...)
:output-buffer "*test pytest*"
:artifacts (...))
#+end_src
***** Adapter Registry
Create a registry like:
#+begin_src elisp
(defvar cj/test-runner-adapters nil)
(cj/test-register-adapter 'pytest ...)
(cj/test-register-adapter 'ert ...)
(cj/test-register-adapter 'vitest ...)
#+end_src
Runner selection should consider:
- buffer file extension
- project files
- explicit user override
- available executables
- package manager scripts
- existing Makefile targets
***** Scope Model
Make scopes explicit and shared across languages:
- =test-at-point=
- =current-file=
- =related-file=
- =focused-files=
- =last-failed=
- =changed=
- =package/module=
- =project=
- =coverage=
- =watch=
Each adapter can say which scopes it supports. Unsupported scopes should produce
clear user-errors with suggestions.
***** Command Builder Pipeline
1. Detect project.
2. Detect language/framework candidates.
3. Resolve user-requested scope.
4. Build structured command object.
5. Optionally let user edit command.
6. Run via =compilation-start= or =make-process=.
7. Parse output/result artifacts.
8. Store normalized result.
9. Update UI/modeline/messages/failure buffer.
***** Keep Makefile Support But Do Not Require It
For this Emacs config, =make test-file= and =make test-name= are useful and
should remain the default Elisp isolated path. But adapter detection should
support:
- direct =emacs --batch= ERT invocation
- =make test=
- =make test-file=
- =make test-name=
- Eask
- Buttercup
**** Elisp-Specific Improvements
***** Add isolated ERT runs
Support batch commands for:
- all project tests
- one test file
- one test name
- focused files
- last failed, once result parsing exists
Use the same Makefile targets in this repo, but design the adapter so other
Elisp projects can run without this Makefile.
***** Support Buttercup/Eask Later
Buttercup uses BDD-style =describe= / =it= suites and is common in Elisp
package testing. Eask is often used to run package tests. Add adapter slots
for these instead of hard-coding ERT forever.
***** Avoid unnecessary global ERT deletion
=cj/ert-clear-tests= is a pragmatic fix for project contamination, but the
stronger long-term answer is isolated runs plus project-scoped discovery. Keep
the cleanup command, but do not make correctness depend on deleting global ERT
state.
**** Python / pytest Ideas
- Detect pytest by =pyproject.toml=, =pytest.ini=, =tox.ini=, =setup.cfg=, or
presence of =tests/=.
- Build commands for:
- project: =pytest=
- file: =pytest path/to/test_file.py=
- test at point: =pytest path/to/test_file.py::test_name=
- class method: =pytest path::TestClass::test_method=
- marker: =pytest -m marker=
- last failed: =pytest --lf=
- failed first: =pytest --ff=
- stop after first: =pytest -x=
- coverage: =pytest --cov=...=
- Parse output for failing node ids and file:line references.
- Read pytest cache for last-failed where useful.
- Offer marker completion by parsing =pytest --markers= or config files.
- Surface xfail/skip separately from hard failures.
**** TypeScript / JavaScript Ideas
***** Detection
Detect runner by project files and scripts:
- =vitest.config.ts/js/mts/mjs=
- =jest.config.ts/js/mjs/cjs=
- =package.json= scripts: =test=, =test:watch=, =vitest=, =jest=
- lockfile/package manager: =pnpm-lock.yaml=, =yarn.lock=, =package-lock.json=,
=bun.lockb=
Prefer project scripts over raw =npx= when present:
- =pnpm test -- path=
- =npm test -- path=
- =yarn test path=
- =bun test path=
***** Scopes
- current file: =vitest run path= or =jest path=
- test at point: use nearest =it= / =test= / =describe= string and pass =-t=
- watch current file
- changed tests where runner supports it
- coverage current file/project
- update snapshots
***** Result Parsing
Parse:
- failing test names
- file paths and line numbers
- snapshot failures
- coverage summary
Treat snapshot updates as an explicit command, not an automatic side effect.
**** Go Ideas
- Detect =go.mod=.
- Current file/source: run package =go test ./pkg=.
- Test at point: nearest =func TestXxx= and run =go test ./pkg -run '^TestXxx$'=.
- Bench at point: nearest =BenchmarkXxx= and run =go test -bench '^BenchmarkXxx$'=.
- Add toggles for =-race=, =-cover=, =-count=1=, =-v=.
- Parse =file.go:line:= output and package failure summaries.
**** Rust Ideas
- Detect =Cargo.toml=.
- Use =cargo test= by default, optionally =cargo nextest run= when available.
- Current test at point: nearest =#[test]= function.
- Current file/module where possible.
- Integration test file: =cargo test --test name=.
- Support =-- --nocapture= toggle.
- Parse compiler/test failures and file:line links.
**** Shell / Generic Ideas
- Adapter for Makefile targets:
- detect =make test=, =make check=, =make coverage=
- expose project-level commands even when language-specific detection fails
- Adapter for arbitrary project command configured in dir-locals or a project
config plist.
- Let users register custom command templates per project:
#+begin_src elisp
((:name "unit"
:command ("npm" "run" "test:unit" "--" "{file}"))
(:name "integration"
:command ("pytest" "tests/integration" "-q")))
#+end_src
**** UI Ideas
***** Transient Menu
Replace or complement the raw keymap with a =transient= menu:
- scope: current test/file/focused/last failed/project
- runner: auto/ert/pytest/vitest/jest/go/cargo/make
- toggles: watch, coverage, debug, fail-fast, verbose, update snapshots
- actions: run, rerun, edit command, show failures, open report
***** Result Buffer
Create a normalized =*Test Results*= buffer:
- latest status per project
- command and duration
- pass/fail/skip counts
- failure list with clickable file:line
- actions to rerun failed/current/all
- links to coverage artifacts
***** Modeline / Headerline Signal
Show the last run status for the current project:
- green passed
- red failed
- yellow running
- gray no run
Keep it quiet and optional.
***** History
Store recent run requests per project:
- rerun last
- rerun last failed
- choose previous command
- compare duration/status against previous run
**** Configuration Ideas
- =cj/test-runner-default-scope=
- =cj/test-runner-prefer-isolated-elisp=
- =cj/test-runner-project-overrides=
- =cj/test-runner-known-adapters=
- =cj/test-runner-enable-watch=
- =cj/test-runner-result-retention=
- per-project override through =.dir-locals.el=
Example:
#+begin_src elisp
((nil . ((cj/test-runner-project-overrides
. (:adapter pytest
:default-args ("-q")
:coverage-args ("--cov=src"))))))
#+end_src
**** Safety And Robustness
- Use structured commands until the final boundary.
- Quote only at render time.
- Avoid shell when =make-process= / =process-file= is sufficient.
- Keep command preview/editing available for surprising cases.
- Detect missing executables before running.
- Add timeouts/cancel commands for long-running or hung tests.
- Do not silently fall back from a missing runner to a different runner unless
the fallback is visible in the command preview.
- Avoid mutating global =load-path= permanently.
- Keep remote/TRAMP behavior explicit; do not accidentally run local commands
for remote projects.
**** Coverage Integration
Tie this into the existing coverage work:
- run coverage for current file/scope
- open latest coverage report
- summarize uncovered lines for current file
- support Elisp SimpleCov/Undercover, pytest-cov, Vitest coverage, Go cover,
and Rust coverage later
- store coverage artifact paths in the normalized run result
**** AI-Assisted Debugging Ideas
- Summarize failing tests from the parsed failure records and raw output.
- Include command, changed files, failure snippets, and relevant source/test
locations.
- Redact env vars, tokens, Authorization headers, and secrets before sending to
=gptel=.
- Add commands:
- =cj/test-runner-explain-failure=
- =cj/test-runner-suggest-related-tests=
- =cj/test-runner-summarize-coverage-gap=
**** Migration Plan
***** Phase 1: Internal cleanup
- Fix the task typo and rename current ERT-specific functions or wrap them under
an ERT adapter.
- Move F6 language detection/command construction from =dev-fkeys.el= into
=test-runner.el= or a new =test-runner-core.el=.
- Replace shell-string command builders with structured command plists.
- Fix path containment in =cj/test--do-focus-add-file=.
- Make =cj/test-last-results= real for ERT runs.
***** Phase 2: ERT adapter
- Implement adapter registry.
- Add ERT adapter with in-process and isolated modes.
- Preserve all current keybindings by routing them through the adapter.
- Add failure/result normalization for ERT.
- Add "rerun last" and "rerun failed" for ERT.
***** Phase 3: Python and JS/TS adapters
- Add pytest adapter.
- Add Vitest/Jest adapter with package-manager/script detection.
- Support current file and test-at-point for both.
- Add parser/navigation for common failures.
***** Phase 4: UI and watch modes
- Add transient menu.
- Add result buffer.
- Add cancellation and rerun history.
- Add watch commands where supported.
***** Phase 5: Coverage and AI
- Connect coverage commands to adapter capabilities.
- Add failure summarization with redaction.
- Add coverage-gap summarization.
**** Acceptance Criteria For First Fix-Up Pass
- Existing ERT workflow still works.
- F6 and =C-; t= use the same underlying runner API.
- Current-file test command generation is covered for Elisp, Python, Go,
TypeScript, and JavaScript.
- At least one isolated ERT command path exists.
- Path containment checks are robust against sibling-prefix paths and symlinks.
- Runner requests and results are represented as data, not only messages.
- Missing runner/tool errors are clear and actionable.
- Tests cover adapter detection, command building, scope resolution, result
storage, and key interactive paths.
** DOING [#B] Module-by-module hardening :harden:nosync:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-22
:END:
Review every file in =modules/= and capture concrete bugs, tests, refactors,
and design improvements as child tasks. This is intentionally separate from the
top-level architecture review: the architecture project tracks cross-cutting
load/startup/test structure, while this project tracks module-specific work.
Re-review pass 2026-05-15:
- Each of the six existing review tracks (foundation, custom editing, UI /
navigation, Org workflow, programming workflow, integrations and
applications) was re-walked as if it had not been reviewed before.
- 32 new sub-task findings filed across the tracks above (foundation 5,
custom editing 6, UI / navigation 9, Org workflow 3, programming 6,
integrations 2). Findings already covered by an existing sub-task were
dropped during consolidation.
- A separate =Review newly added modules= task lists the 24 modules that
were either added after the parent task was written (post-2026-04) or
fell outside the original scope lists. Each is routed to its target
track; module-specific findings are filed under the relevant track.
Review protocol for each module:
- Read the module directly, not just the test names.
- Check runtime dependencies, top-level side effects, keybindings, timers,
external executable assumptions, secrets, host-specific paths, and user-data
writes.
- Check existing test coverage and whether tests protect the highest-risk
behavior.
- Promote larger findings into child =PROJECT= tasks with phases. Keep small
fixes as plain =TODO= tasks.
Priority scheme: use the top-level =Priority Scheme= section in this file.
Suggested review order:
1. Foundation: =system-lib=, =user-constants=, =host-environment=,
=system-defaults=, =keybindings=, =config-utilities=, =early-init=,
=init=.
2. Custom editing utilities: =custom-*=, =external-open=, =media-utils=.
3. UI and navigation: =ui-*=, =font-config=, =modeline-config=,
=selection-framework=, =mousetrap-mode=, =popper-config=.
4. Org workflow: =org-*=, =calendar-sync=, =hugo-config=, =gloss-config=.
5. Programming workflow: =prog-*=, =dev-fkeys=, =test-runner=,
=coverage-*=, =vc-config=.
6. Integrations and applications: mail, Slack, ERC, Elfeed, EWW, Dirvish,
PDF, Calibre, music, recording/transcription, AI/rest tooling.
*** DOING [#B] Harden foundation modules :harden:
Scope:
- =system-lib.el=
- =user-constants.el=
- =host-environment.el=
- =system-defaults.el=
- =keybindings.el=
- =config-utilities.el=
- =early-init.el=
- =init.el=
Expected output:
- Add one child task for each actionable finding.
- Note "no action" only when the module has been reviewed and no task is
needed.
- Cross-reference existing architecture tasks instead of duplicating them.
Review progress:
- =system-lib.el=: reviewed 2026-05-03. No immediate action beyond the existing
[#B] system-lib extraction task.
- =host-environment.el=: reviewed 2026-05-03. See child tasks below.
- =user-constants.el=: reviewed 2026-05-03. See child tasks below.
- =system-defaults.el=: reviewed 2026-05-03. See child tasks below.
- =keybindings.el=: reviewed during architecture pass. No new module-specific
action beyond the load-order/keymap architecture tasks.
- =config-utilities.el=: reviewed 2026-05-03. No new module-specific action;
profiling extraction is already tracked by [#B] "Build debug-profiling.el
module".
- =early-init.el=: reviewed 2026-05-10. See child tasks below and the existing
[#B] "Split early startup from package bootstrap" task.
- =init.el=: reviewed 2026-05-10. See child tasks below and the existing
eager load-graph architecture tasks.
Completion review 2026-05-15:
- Re-read the parent =Module-by-module review and hardening= context and the
adjacent architecture follow-up so this review stays module-specific.
- Re-checked all scoped files against the review protocol. Existing child
tasks below still cover the actionable module findings for
=user-constants.el=, =host-environment.el=, =system-defaults.el=, and
=early-init.el=.
- =system-lib.el=, =keybindings.el=, =config-utilities.el=, and =init.el= do
not need additional module-specific child tasks from this pass; remaining
concerns are already tracked by the utility-consolidation, keymap
registration, debug-profiling, and eager-load-graph architecture tasks.
**** 2026-05-25 Mon @ 19:12:02 -0500 Split path constants from filesystem init in user-constants.el
=(require 'user-constants)= used to create ~8 directories and ~10 org/calendar
files at load — the source of the stray =sync/org/= tree that appeared in the
repo during test runs. Both load-time forms are gone now; the path defconsts
stay pure, and init.el calls =cj/initialize-user-directories-and-files= on real
startup (guarded by =(unless noninteractive)=) so a bare require is
side-effect-free. Verified end-to-end: a require creates nothing, and the
interactive guard creates the backbone dirs and files. Landed in two commits on
the =refactor/user-constants-defer-fs-init= branch.
***** 2026-05-25 Mon @ 19:12:02 -0500 Extracted pure path definitions from startup writes
Removed the top-level calendar =dolist= and the top-level initializer call, and
folded gcal/pcal/dcal into =cj/initialize-user-directories-and-files=. init.el
now calls it right after the require, guarded by =(unless noninteractive)=.
Added =tests/test-user-constants.el= (loading creates nothing; the initializer
creates the configured paths) and updated the module header — top-level side
effects are now none and it's safe to load in tests.
***** 2026-05-25 Mon @ 19:12:02 -0500 Made initialization failures actionable
=cj/verify-or-create-dir=/=-file= took an optional =required= flag routed
through =cj/--report-path-failure=: required failures raise a prominent
=display-warning=, optional ones are logged. The initializer groups paths by
that split — required: the sync/org/roam dirs and the gcal/pcal/dcal stubs;
optional: the secondary dirs and content files. Chose a warning over a
=user-error= so a directory hiccup surfaces loudly without aborting init. Added
error-path tests for the optional-logs and required-warns behavior.
**** 2026-05-23 Sat @ 03:33:30 -0500 Fixed env-desktop-p doc and normalized the X predicates
Corrected =env-desktop-p='s docstring (it described a laptop; the function returns t for the desktop/no-battery case). Switched =env-x-p= from =(string= (window-system) "x")= to =(eq (window-system) 'x)= to match =env-x11-p='s style, and documented the difference: =env-x-p= is any X display incl. XWayland, =env-x11-p= is a real X11 session with no WAYLAND_DISPLAY. Behavior unchanged, existing display-predicate tests stay green. Fixed in 14ec32b2.
Left =cj/match-localtime-to-zoneinfo= caching alone — it was a "consider if this runs during startup" note, not an acceptance item, and it doesn't run at startup. File a separate task if it ever shows up in a profile.
**** 2026-05-25 Mon @ 16:59:37 -0500 Added system-defaults settings smoke tests
Added =tests/test-system-defaults.el= with three settings assertions the
existing files didn't cover: custom-file is redirected to a temp trashbin
(not the repo), backups land under =user-emacs-directory/backups=, and the
minibuffer GC hooks are wired onto the minibuffer hooks. The module's
functions were already covered by =test-system-defaults-functions.el= and the
=vc-follow-symlinks= default by its own file, so this stayed narrow to the
settings gap. Extracted the shared sandbox loader into
=tests/testutil-system-defaults.el= so both the new file and the
vc-follow-symlinks test use one copy. The backups test clears
=cj/backup-directory= first because it's a defvar that only recomputes when
unbound.
**** TODO [#B] Move package bootstrap policy out of =early-init.el= :startup:refactor:
=early-init.el= currently handles performance/debug setup, package archive
construction, archive refresh policy, =use-package= installation, package
signature policy, and Unicode defaults. That makes early startup do network- and
package-manager-adjacent work before the regular module system exists.
This overlaps with the existing [#B] "Split early startup from package
bootstrap" task; keep the implementation there if that task is already active.
This foundation review finding is the module-level acceptance detail.
Expected outcome:
- =early-init.el= keeps only settings that must happen before normal init:
startup GC/file-handler tuning, debug flag setup, native-comp workaround,
=load-prefer-newer=, site-start suppression, and package startup suppression.
- Package archive setup, refresh/install policy, and =use-package= bootstrap
live in a normal module or bootstrap helper that can be tested directly.
- Offline and missing-package states produce actionable errors without doing an
unexpected package refresh during early startup.
- Existing local repo and ELPA mirror behavior is preserved.
Pitfalls:
- Do not break first-run bootstrap on a clean machine.
- Keep local repositories higher priority than online archives.
- Avoid prompting or refreshing archives during batch tests.
**** TODO [#B] Decide and test package signature policy :security:startup:
=early-init.el= sets =package-check-signature= to =nil= after package setup, with
an earlier commented emergency toggle for expired signatures. That may be
intentional for local mirrors, but it is security-sensitive enough to make the
policy explicit.
Expected outcome:
- Document when signatures should be disabled, if ever.
- Prefer signatures on for online archives unless a local-mirror workflow
requires otherwise.
- If signatures stay disabled, add a clear comment explaining the trust model.
- Add a small test or validation helper around the computed package policy if
package bootstrap is extracted.
**** 2026-05-16 Sat @ 02:34:22 -0500 Consolidated user-home-dir into early-init as canonical
Canonical defconst in =early-init.el= kept (the package-archive paths
need it during package bootstrap, before normal modules load).
=modules/user-constants.el= switched to a `defvar` with the identical
=(getenv "HOME")= expression and a comment explaining the pattern:
defvar is a no-op at runtime (early-init's defconst wins, defvar
doesn't reassign a bound symbol), but it lets the module load /
byte-compile standalone when early-init hasn't run. Drift risk is
mitigated by both expressions being =(getenv "HOME")= literally; the
comment flags the requirement to keep them identical.
**** 2026-05-16 Sat @ 02:34:22 -0500 Dropped redundant autoload alongside compile-time require in system-defaults.el
Kept the =eval-when-compile= requires for =host-environment= and
=user-constants= (they silence free-variable / free-function warnings
during byte-compile in isolation) and dropped the
=(autoload 'env-bsd-p ...)= line — both modules are loaded earlier in
init.el at runtime, and the eval-when-compile already exposes
=env-bsd-p= to the byte-compiler. Added a comment documenting the
chosen boundary.
**** 2026-05-16 Sat @ 02:34:22 -0500 Converted cj/debug-modules and cj/use-online-repos to defcustom
Both toggles now live as =defcustom= with explicit =:type= and
=:group 'cj=. =cj/debug-modules='s type is the natural choice form:
either =t= (all modules) or a list of module symbols.
=cj/use-online-repos='s type is boolean. Added a top-level
=(defgroup cj ...)= in early-init.el so the group exists for both,
plus the package-priority constants below it.
**** 2026-05-25 Mon @ 18:29:40 -0500 Made the Customize-save discard non-silent
Took the display-warning option. =cj/--warn-customize-discarded= advises
=custom-save-all= (the chokepoint both =customize-save-variable= and the
Customize "Save for Future Sessions" button funnel through) with a one-shot
=:before= warning that explains the edit won't persist and points at the Elisp
init files. The advice removes itself after firing, so it warns once per
session, and the body never runs at load, so startup stays quiet. Kept the
throwaway =custom-file= as-is. Test added in =tests/test-system-defaults.el=.
**** 2026-05-16 Sat @ 02:34:22 -0500 Named the package archive priorities in early-init.el
Nine =defconst= entries replace the magic numbers:
=cj/package-priority-localrepo= (200) for the project-pinned repo,
four =cj/package-priority-mirror-*= entries for the local ELPA
mirrors (125 / 120 / 115 / 100), four =cj/package-priority-online-*=
entries for the online archives (25 / 20 / 15 / 5). A header comment
above the block explains the local-first ordering and the
gnu > nongnu > melpa > melpa-stable trust ranking within each tier.
**** 2026-05-16 Sat @ 02:34:22 -0500 Deleted dead world-clock block in chrono-tools.el
The 19-line commented-out =use-package time= block is gone. The
=time-zones= use-package directly above it is the active replacement;
git history preserves the old config if anyone needs to dig it back up.
**** 2026-05-16 Sat @ 02:34:22 -0500 Added coverage for cj/tmr-select-sound-file with a pre-test refactor
The prefix-arg branch now delegates to =cj/tmr-reset-sound-to-default=
directly (single source for the reset path). Extracted a pure helper
=cj/tmr--available-sound-files= so the directory scan is testable
without driving =completing-read=. =tests/test-chrono-tools-tmr-sound.el=
covers Normal / Boundary / Error: available-sounds against a populated
dir, empty dir, missing dir; reset path; select with prefix-arg
(delegates to reset, no prompt); select normal (picks a file); select
boundary paths for empty dir, missing dir, cancel (empty completion).
9 tests, all green.
*** DOING [#B] Harden custom editing utility modules :harden:
Scope:
- =custom-buffer-file.el=
- =custom-case.el=
- =custom-comments.el=
- =custom-datetime.el=
- =custom-line-paragraph.el=
- =custom-misc.el=
- =custom-ordering.el=
- =custom-text-enclose.el=
- =custom-whitespace.el=
- =external-open.el=
- =media-utils.el=
Review progress:
- Core =custom-*= text modules reviewed 2026-05-03. They have unusually strong
direct ERT coverage compared with the rest of the config.
- =external-open.el= and =media-utils.el= reviewed 2026-05-03. See child tasks.
- =custom-buffer-file.el= reviewed 2026-05-03. See child tasks.
Completion review 2026-05-15:
- Re-checked the scoped custom editing utility modules and their test files.
- The pure editing modules remain well covered by focused ERT tests.
- Remaining actionable issues are already logged below: process-launch
hardening and coverage for =external-open.el= / =media-utils.el=,
destructive buffer/file keybinding policy, and explicit cross-module
autoload/require boundaries.
**** TODO [#B] Harden external process launching in =external-open.el= and =media-utils.el= :security:refactor:
=external-open.el= and =media-utils.el= use shell command strings for launching
external applications:
- =cj/open-this-file-with= interpolates the user-supplied command into
=call-process-shell-command=.
- =cj/media-play-it= builds a shell command for players and optional =yt-dlp=
stream extraction.
This is mostly controlled local input, but it is still brittle: command paths
with spaces can fail, arguments are hard to reason about, and future URL/source
changes could create shell quoting bugs.
Expected outcome:
- Prefer =start-process= / =call-process= with argv lists where possible.
- If shell is required for command substitution, isolate and quote every
untrusted value.
- Add tests around command construction for:
- file paths with spaces and shell metacharacters,
- URL strings with shell metacharacters,
- configured player args,
- missing executable errors.
Pitfalls:
- =cj/open-this-file-with= may intentionally accept "program plus args". If so,
split the command deliberately or introduce separate program/args prompts.
- Some media players need different URL handling; preserve the existing
=:needs-stream-url= behavior.
**** 2026-05-23 Sat @ 03:41:00 -0500 Added media-utils coverage; external-open already covered
=external-open= already had three test files (=test-external-open-commands.el=, =test-external-open-lib-command.el=, =test-external-open-lib-launcher-p.el=). =media-utils.el= had none, so I added =test-media-utils.el= (8 cases): player availability from =cj/media-players=, the play command-builder (direct vs yt-dlp -g stream wrap), and the missing-tool error paths for the player, =yt-dlp=, and =tsp=. All process/exec boundaries mocked. Added in the test-media-utils commit.
**** TODO [#B] Audit destructive buffer/file keybindings for confirmation policy :ux:
=cj/buffer-and-file-map= includes destructive operations under =C-; b=,
including delete file, erase buffer, clear top, clear bottom, and revert. Some
are intentionally fast, but this module is high blast radius.
Expected outcome:
- Decide which operations need confirmation when the buffer is modified or
visiting a file.
- At minimum, document the intended policy in =custom-buffer-file.el=.
- Consider safer wrappers for =erase-buffer= and =revert-buffer= under the
personal keymap.
**** 2026-05-24 Sun @ 14:43:13 -0500 Declared cross-module commands bound in custom keymaps
Byte-compiling =custom-ordering.el= and =custom-text-enclose.el= standalone warned "not known to be defined" for =cj/org-sort-by-todo-and-priority= (owned by org-config) and =change-inner=/=change-outer= (the change-inner package). Both work at runtime — org-config loads eagerly, and text-config autoloads change-inner via =use-package :commands= — so only the compiler needed telling; added =declare-function= for each (no autoload needed since the runtime autoload/eager-load already exists). =custom-buffer-file.el= byte-compiles clean already, so it needed no change. Commit =ad173a77=.
**** 2026-05-24 Sun @ 07:26:31 -0500 Extracted shared region-or-buffer bounds helper
The described docstring mismatch was already resolved: =custom-ordering.el='s =cj/--arrayify=/=cj/--unarrayify= now document an explicit =(start end)= contract accurately and are region-required by design. The remaining work was the enclose side, where append/prepend/indent/dedent each inlined the same region-or-buffer bounds block (four copies). Extracted =cj/--region-or-buffer-bounds= as the single source of that contract and routed all four through it (behavior unchanged; public-wrapper tests still pass). Each pair now has one clear, consistent, documented contract. New tests cover the helper (region / no-region / empty buffer). Commit =a7cc8948=.
**** 2026-05-16 Sat @ 02:47:15 -0500 Preserved trailing newlines in custom-ordering output
Both =cj/--arrayify= and =cj/--unarrayify= now detect a trailing
newline on the input region (via =string-suffix-p=) and re-append it
to the result. Matches the pattern =custom-text-enclose.el= already
uses. Docstrings updated to document the contract.
**** 2026-05-16 Sat @ 02:47:15 -0500 Guarded cj/duplicate-line-or-region against modes without comment syntax
=cj/duplicate-line-or-region= now signals a clear =user-error= when
=COMMENT= is non-nil but the current mode has no =comment-start=
(=fundamental-mode= and similar). Docstring updated to document the
error. Picks the "error out clearly" branch from the task body --
restricting the binding per-mode would be a larger refactor that
isn't justified for the silent-malformed-output blast radius.
**** 2026-05-16 Sat @ 02:47:15 -0500 Made external-open advice install explicit via cj/external-open-install-advice
Factored the =advice-remove= / =advice-add= pair into
=cj/external-open-install-advice= and called it once at the bottom
of the module. Same idempotent shape (remove-then-add) but now the
intent is named. Re-running the module updates the advice rather
than stacking it; if a future caller wants to opt out, they can
=advice-remove= the helper or skip calling it altogether.
**** 2026-05-16 Sat @ 02:47:15 -0500 Added cj/--validate-decoration-char across all six divider/border helpers
New top-level validator =cj/--validate-decoration-char= rejects
anything that isn't a printable single-character string and signals
=user-error=. Wired into all six internal helpers:
=cj/--comment-inline-border=, =cj/--comment-simple-divider=,
=cj/--comment-padded-divider=, =cj/--comment-box=,
=cj/--comment-heavy-box=, =cj/--comment-block-banner=. The five
nil-decoration ERT tests updated from =:type 'wrong-type-argument=
(the old crash signal from =string-to-char= on nil) to
=:type 'user-error=, since the guard now produces a clear message
instead of a deep crash.
**** 2026-05-23 Sat @ 03:38:30 -0500 Filled the remaining title-case edge gaps
=test-custom-case-title-case-region.el= already had 29 cases (empty region, unicode words, numbers, separators, colon resets, partial region). The named gaps that were missing — leading-quote and leading-paren handling, plus a caseless RTL first word — are now covered by three boundary tests (32 total). Added in 3841c59e.
**** 2026-05-16 Sat @ 02:47:15 -0500 Extracted cj/--require-spell-checker
Both =cj/flyspell-toggle= and =cj/flyspell-then-abbrev= now call
=cj/--require-spell-checker= instead of carrying their own copy of
the executable-find check. The checker list lives in the new
defconst =cj/--spell-checker-executables=, so adding nuspell (or any
other checker) is a one-line edit. Top-level =(require 'cl-lib)=
added since the new helper uses =cl-some=.
**** 2026-05-23 Sat @ 03:44:50 -0500 Covered flyspell-and-abbrev testable seams
Added =test-flyspell-and-abbrev.el= (8 cases): =cj/--require-spell-checker= (PATH gate, mocked), =cj/find-previous-flyspell-overlay= against synthetic overlays (closest-previous match, non-flyspell skipped, nil when none), and =cj/flyspell-on-for-buffer-type= (prog-mode -> flyspell-prog-mode, text-mode -> flyspell-mode). Left =cj/flyspell-then-abbrev= to manual testing — pinning its flyspell-UI orchestration would mean mocking flyspell internals rather than our logic. Added in the test-flyspell-abbrev commit.
*** DOING [#B] Harden UI and navigation modules :harden:
Scope:
- =ui-config.el=
- =ui-navigation.el=
- =ui-theme.el=
- =font-config.el=
- =modeline-config.el=
- =selection-framework.el=
- =mousetrap-mode.el=
- =popper-config.el=
Review progress:
- Reviewed 2026-05-03.
- =mousetrap-mode.el= has strong focused and integration tests.
- =modeline-config.el= has pure string-helper coverage, but not VC/runtime
segment behavior.
- =font-config.el=, =ui-theme.el=, =selection-framework.el=, =ui-navigation.el=,
and =popper-config.el= have little direct test coverage.
Completion review 2026-05-15:
- Re-checked the scoped UI/navigation modules and current tests.
- =mousetrap-mode.el=, =ui-navigation.el=, =ui-theme.el=,
=selection-framework.el=, and selected modeline/UI helpers now have focused
tests, but the font/modeline/popper runtime policy remains under-tested.
- Existing cleanup below covers the disabled =popper-config.el= load-graph
issue; added a separate test-gap task for the remaining UI smoke coverage.
**** 2026-05-25 Mon @ 17:05:00 -0500 Added UI/navigation runtime smoke coverage
Added =tests/test-font-config.el= (4 tests): cj/font-installed-p returns t/nil
off find-font, and cj/apply-font-settings-to-frame is a no-op on a non-GUI
frame and applies the preset exactly once per frame (idempotent). find-font,
env-gui-p, and fontaine-set-preset are stubbed so the run stays headless, and
a skip-unless on the demanded packages keeps a bare checkout green.
font-config had zero direct coverage; this fills the gap the task named.
modeline-config was already well covered (string-cut-middle, string-truncate-p,
vc-cache, vc-cache-key, the flycheck segment, the recording indicator), so it
needed no net-new smoke tests. popper-config's no-op smoke test is gated on the
"Decide whether popper-config.el should exist while disabled" task and was
deferred there, since whether to write it depends on that keep/remove call.
Original scope:
- =font-config.el=: font fallback/daemon frame setup does not error when
optional fonts or emoji packages are absent.
- =modeline-config.el=: runtime segment assembly handles missing VC/project
data and does not signal in non-file buffers.
- =popper-config.el=: if the module remains in =init.el= while disabled, a
smoke test should prove requiring it is an intentional no-op.
**** 2026-05-26 Tue @ 17:33:28 -0500 Removed popper-config.el (disabled no-op)
Deleted =modules/popper-config.el= and its =(require 'popper-config)= in =init.el= (commit 1cca84c5). It was =use-package :disabled t=, so use-package elided the whole form and it ran nothing while still sitting in the load graph. No test existed and none was needed. validate-modules passes and init loads clean. The config stays in git history if popper is ever wanted.
***** 2026-05-26 Tue @ 15:15:43 -0500 Decided: remove popper-config.el
Craig's call: remove it (quick, solo). It has been a disabled no-op in the load graph. Remaining action: drop =(require 'popper-config)= from =init.el= and delete =modules/popper-config.el= (and any test), then close this task.
**** 2026-05-16 Sat @ 02:55:14 -0500 Moved popper-mode activation from :init to :config
=popper-mode +1= and =popper-echo-mode +1= now live in the
=:config= block of =modules/popper-config.el='s use-package form.
=:disabled t= now actually disables the mode (=:config= is skipped
when disabled; =:init= still runs but it only sets the reference-
buffer list and the display-buffer-alist entry, both of which are
harmless no-ops when popper itself never loads). Comment in the
module explains the split.
**** 2026-05-16 Sat @ 02:55:14 -0500 Made cj/modeline-vc-fetch fall back when vc-git--symbolic-ref is missing
The =require 'vc-git= now uses =nil 'noerror=, and the call to
=vc-git--symbolic-ref= is gated on =(fboundp ...)= so an Emacs
version that renames or removes the internal accessor just leaves
=branch= at =vc-working-revision='s output instead of crashing the
modeline render. Added =ignore-errors= around the call too in case
the internal accessor signals on unusual inputs.
**** 2026-05-25 Mon @ 18:18:29 -0500 Theme-aware font label face in cj/display-available-fonts
Replaced the hardcoded =((:foreground "Light Blue" :weight bold))= label
face in =modules/font-config.el= with =(font-lock-keyword-face (:weight
bold))= so the family header follows theme contrast instead of being
unreadable on light themes. The Regular/Bold/Italic sample lines stay as-is
(they render in each font family on purpose).
=modules/font-config.el:266= hardcodes ="Light Blue"= and gray
foreground for font labels. Switching themes (especially light
themes) makes the labels nearly unreadable. Replace the literal
color with a face reference (=font-lock-keyword-face= or a face this
config owns) so the labels follow theme contrast.
**** 2026-05-25 Mon @ 18:18:29 -0500 Routed emoji fontset through per-frame hook in daemon mode
The emoji-fontset =(when (env-gui-p) (cond ...))= block in
=modules/font-config.el= ran once at load. In daemon mode =env-gui-p= is
nil at load (no GUI frame yet), so a later =emacsclient -c= frame inherited
no emoji fontset. Wrapped it in =cj/setup-emoji-fontset= (idempotent, GUI-
guarded) and, mirroring the fontaine pattern, added it to
=server-after-make-frame-hook= in daemon mode / ran it directly otherwise.
The all-the-icons install path already used the per-frame hook, so it was
left alone. Manual daemon TTY-then-GUI test added under "Manual testing and
validation" (batch can't drive it).
**** 2026-05-25 Mon @ 18:05:56 -0500 Cached mousetrap keymaps per profile
=mouse-trap--build-keymap= rebuilt the whole keymap on every major-mode hook.
Moved the build into =mouse-trap--build-keymap-1= and cached its result in
=mouse-trap--keymap-cache=, keyed on =(profile-name . allowed-categories)=, so
the same profile reuses the cached map and editing a profile's categories
changes the key and rebuilds. Sharing one keymap object across buffers is safe
since the map only binds disallowed events to =ignore= and is never mutated.
Added =mouse-trap--clear-keymap-cache=. Behavior is unchanged; 5 cache tests
plus the existing 66 mousetrap tests pass. Done by subagent, reviewed.
**** 2026-05-24 Sun @ 07:26:31 -0500 Keyed VC modeline cache on resolved truename
=cj/modeline-vc-cache-key= keyed on =(list file cj/modeline-vc-show-remote)=, so a symlink whose target moved (shared drives, CI workspaces) kept serving the old VC backend. Added =(file-truename file)= to the key — one stat per refresh, cheap next to the VC calls the cache avoids — so a re-pointed symlink produces a different key and refreshes. Tests cover truename inclusion, stability for an unchanged file, and a symlink whose target moves. Commit =9135298c=.
**** 2026-05-24 Sun @ 04:01:02 -0500 Verified C-s already advances isearch — non-bug, no change
The premise didn't hold on Emacs 30.2. Investigated in the live daemon: while isearch is active, =overriding-terminal-local-map= is =isearch-mode-map= and the effective =C-s= resolves to =isearch-repeat-forward=, not =cj/consult-line-or-repeat=. isearch installs its own map as an overriding map, so the global =C-s= binding can't shadow it during a search. =isearch-mode-map= already binds =C-s= to =isearch-repeat-forward= by default. Adding an explicit binding to the consult =:bind= block would only duplicate that default, so I left =selection-framework.el= unchanged.
**** 2026-05-16 Sat @ 02:55:14 -0500 Guarded cursor-color hook behind display-graphic-p (with daemon-mode catch)
Both the install (=add-hook= on =post-command-hook=) and the function
body now gate on =(display-graphic-p)=. Batch and TTY runs short-
circuit cleanly: no per-command overhead, no =set-cursor-color= calls
on frames that don't have a cursor color. A =server-after-make-frame-hook=
catches the daemon case where the first GUI frame is created after
=ui-config= loads -- it installs the hook lazily the first time a
GUI frame appears.
The two cursor-color test files
(=test-ui-config--buffer-cursor-state.el=,
=test-ui-cursor-color-integration.el=) stub =display-graphic-p= to
return t so the work body still runs in batch.
**** 2026-05-16 Sat @ 02:55:14 -0500 Deferred nerd-icons by dropping :demand t plus an after-load safety net
=(use-package nerd-icons :demand t ...)= flipped to =:defer t=. The
=:config= block already wraps the advice + tint in lazy-on-load
semantics, so the advice now installs the first time nerd-icons
loads (typically when a feature module like =dashboard-icon-type=
or =dirvish-attributes= triggers a load). An additional
=(with-eval-after-load 'nerd-icons ...)= block at module bottom
catches the "already-loaded when this module re-evaluates" case --
it checks =advice-member-p= so it doesn't stack the advice on every
re-eval.
*** DOING [#B] Harden Org workflow modules :harden:
Scope:
- =org-config.el=
- =org-agenda-config.el=
- =org-babel-config.el=
- =org-capture-config.el=
- =org-contacts-config.el=
- =org-drill-config.el=
- =org-export-config.el=
- =org-noter-config.el=
- =org-refile-config.el=
- =org-reveal-config.el=
- =org-roam-config.el=
- =org-webclipper.el=
- =calendar-sync.el=
- =hugo-config.el=
- =gloss-config.el=
Review progress:
- Reviewed 2026-05-03 at high level.
- =calendar-sync.el= has substantial focused coverage for parsing, recurrence,
timezone conversion, event conversion, and regressions. The largest remaining
risks are configuration/secrets, startup side effects, and process/network
boundaries.
- =org-agenda-config.el= and =org-refile-config.el= now have useful cache tests,
but the cache lifecycle and startup idle timers still deserve a design pass.
- =org-noter-config.el= already has an older [#B] workflow VERIFY task. Do not
duplicate that work here.
- =hugo-config.el= and =org-reveal-config.el= have focused helper coverage.
- =gloss-config.el= is a thin package wrapper; no local unit-test target unless
custom glue is added.
- Deeper pass 2026-05-10 added follow-up tasks for org-roam done hooks, drill
file selection/package loading, Org export defaults, Babel templates, and
contact/Mu4e boundaries.
Completion review 2026-05-15:
- Re-checked the scoped Org workflow modules and their test coverage.
- The broad parser/cache/helper areas now have useful focused tests, especially
=calendar-sync.el=, agenda/refile helpers, org-roam helpers, org-noter, Hugo,
reveal, and webclipper processing.
- Remaining issues are already logged below, including security-sensitive
calendar config and Babel evaluation policy, cache lifecycle/timer behavior,
org-roam destructive workflow guardrails, executable checks, capture-template
smoke tests, and Org workflow ownership documentation.
**** 2026-05-23 Sat @ 04:18:44 -0500 Split personal calendar config out of calendar-sync.el
=calendar-sync.el= now defaults =calendar-sync-calendars= to nil and loads the real plists from =calendar-sync-private-config-file= (an ignored file), so the engine carries no private feed tokens in source. =calendar-sync-status= / =calendar-sync-start= report missing config without erroring, and agenda startup is unaffected (tests/test-calendar-sync-no-config-startup.el). Rotating the previously-committed feed URLs remains a manual credential action — tracked under the L2557 calendar-sync hardening finding.
**** PROJECT [#B] Normalize Org agenda/refile cache lifecycle :perf:refactor:
Two of three children are done (shared cache helper extracted, idle timers gated). Still open: the directory-scan-failure visibility child below.
***** 2026-05-23 Sat @ 04:18:44 -0500 Extracted a shared cache helper
=cj-cache-lib.el= now provides =cj/cache-valid-p=, =cj/cache-building-p=, and =cj/cache-value-or-rebuild=, consumed by both =org-agenda-config.el= and =org-refile-config.el=; the contract is documented in =docs/design/cache-helper-design.org=. The agenda and refile public commands are unchanged.
***** 2026-05-24 Sun @ 07:26:31 -0500 Surfaced directory-scan failures instead of hiding/crashing
The refile scan caught =permission-denied= and silently dropped the dir, and crashed outright on a missing root (only permission-denied was caught, so a missing =code-dir=/=projects-dir= raised =file-missing= and aborted the build); the agenda build had the same missing-dir crash via =directory-files=. Extracted =cj/--org-refile-scan-dir= (warns + returns nil for missing/unreadable/permission-denied so the scan continues) and guarded the agenda scan the same way. Also fixed a latent bug found here: =org-refile-targets= was never declared special, so under =make compile= =cj/org-refile-in-file= let-bound it lexically and the scoped targets never reached =org-refile= — added =(defvar org-refile-targets)=. Tests cover the helper + the agenda missing-dir guard. Commit =12fb0108=.
***** 2026-05-23 Sat @ 04:18:44 -0500 Gated cache idle timers out of batch
Both cache builders' =run-with-idle-timer= calls are wrapped in =(unless noninteractive)= (=org-refile-config.el:105=, =org-agenda-config.el:203=), so requiring the modules in batch schedules nothing. =tests/test-architecture-startup-contracts.el= scans every module and asserts there are no unguarded top-level timer forms.
**** 2026-05-23 Sat @ 19:48:00 -0500 Made org-confirm-babel-evaluate default to t with a toggle
=org-babel-config.el= set =org-confirm-babel-evaluate= to nil globally, so every source block in every Org file (cloned repos, downloaded notes, web clips) ran without confirmation. Changed the default to =t= (confirm before running). Replaced the old =babel-confirm= command (which reported, and toggled only with a prefix arg) with =cj/org-babel-toggle-confirm=, a plain toggle bound to =C-; k= for flipping confirmation off in trusted files and back on. 3 ERT tests cover the toggle both directions plus the binding.
**** TODO [#B] Rebind babel-confirm toggle off =C-; k= :keybinding:solo:discuss:
=cj/org-babel-toggle-confirm= landed on =C-; k= as a placeholder. Pick a permanent home — likely under an Org-specific prefix rather than the global =C-;= map.
Triggered by: 2026-05-23 org-confirm-babel-evaluate hardening.
**** 2026-05-24 Sun @ 14:43:13 -0500 Guarded move-branch-to-roam against data loss
The command cut the subtree from the source before writing the new roam file, so any failure in demote/format/write/db-sync lost the subtree with no rollback. Reordered to write and verify the file on disk before =org-cut-subtree=, so a failed write aborts with the source intact. Added a no-clobber guard (refuse an existing target file) and a confirmation prompt for large subtrees (>= =cj/move-org-branch-confirm-lines=, 30) or buffers with unsaved changes. Decided: leave the source buffer modified and undoable rather than auto-saving, so the move stays reversible. New test drives the write-failure-preserves-source invariant via an unwritable roam dir. Commit =5c0fa15d=.
**** 2026-05-25 Mon @ 18:29:40 -0500 Already done — clip URL/title scoped to dynamic bindings
Found this already shipped in =6dfc41af= ("refactor(webclipper): scope clip
URL/title to dynamic bindings", 2026-05-24). The temp vars =cj/--webclip-url= /
=cj/--webclip-title= are now =let=-bound around the org-capture call instead of
=setq='d and manually cleared, so they unwind on every exit path including a
=C-g= abort — which covers the "aborted captures clear temp state" outcome more
completely than a finalize hook would. The bookmarklet/org-protocol workflow is
unchanged. =tests/test-org-webclipper-commands.el= already covers the
leaves-no-stale-state and aborted-capture-clears-state cases. No new work needed.
**** 2026-05-25 Mon @ 17:51:17 -0500 Guarded external-tool assumptions in Org export/publishing
Added command-time guards to four export/publishing commands so a missing tool
fails with a user-error that names it, instead of an opaque process error (or,
for reveal.js, a silently broken presentation):
- =org-export-config.el=: zathura check in my/org-pandoc-export-to-pdf-and-open.
- =hugo-config.el=: hugo binary check in cj/hugo-preview, and the platform
file-manager opener check in cj/hugo-open-blog-dir-external.
- =org-reveal-config.el=: extracted =cj/--reveal-ensure-root= (checks the local
reveal.js clone, points at scripts/setup-reveal.sh), called from
cj/reveal-export and cj/reveal-preview-start.
- =org-webclipper.el=: pandoc check in cj/org-protocol-webclip-handler, the
single path that shells out to pandoc via org-web-tools.
All checks run at command time, not load, so startup stays quiet. Each guard
has a user-error test; existing happy-path tests now stub the lookups. Things
deliberately not guarded: hugo-publish (magit, internal), the preview filter
(browse-url, internal), hugo-export-post (ox-hugo, Elisp). Done with four
parallel implementation agents, reviewed individually; full suite green.
**** 2026-05-16 Sat @ 03:44:45 -0500 Guarded org-roam completed-task hook with cj/--org-roam-should-copy-completed-task-p
=org-roam-config.el= adds a global =org-after-todo-state-change-hook= that
copies newly completed tasks to today's org-roam journal. The hook assumes the
current Org buffer is visiting a file:
- It calls =(buffer-file-name)= and passes the result to =string=.
- =cj/org-roam-copy-todo-to-today= later compares =file-truename= of the daily
file and the current buffer file.
That can error in capture buffers, indirect buffers, temporary Org buffers, or
other fileless Org workflows.
Expected outcome:
- Extract a predicate for "should copy this completed task to today's journal".
- Skip fileless buffers, calendar sync files, aborted capture buffers, and tasks
already in the target daily file.
- Keep the normal completed-task journal workflow unchanged.
- Add tests for fileless buffers, =gcal-file=, already-daily buffers, and a
normal project/todo buffer.
**** 2026-05-24 Sun @ 04:30:14 -0500 Shared one validated drill-file selector
org-capture-config.el and org-drill-config.el each scanned =drill-dir= with an inline =directory-files= call, so a missing/empty/unreadable dir surfaced as a low-level error or an empty =completing-read= depending on which command ran. Added =cj/--drill-files-or-error=, the single validated entry point: clear =user-error= when the dir is missing, unreadable, or has no drill files; otherwise the list. =cj/--drill-pick-file= and both drill capture templates route through it; the pure =cj/--drill-files-in= primitive and its tests are unchanged. Tests cover missing/empty/non-org/normal. Commit =49038c41=.
**** 2026-05-24 Sun @ 14:43:13 -0500 Removed contradictory org-export-with-tasks default
=org-export-config.el= set =org-export-with-tasks= twice (=("TODO")= then =nil=); the final =nil= won but the stale first line + comment contradicted it. Removed the leftover. =nil= (export no tasks) is the deliberate default — it was already winning, its comment matches, and it sits with the adjacent "without tags / section numbers by default" settings. Added a smoke test that fires the deferred =ox= :config and pins the value to =nil=. Commit =94ef5242=.
**** 2026-05-23 Sat @ 03:48:50 -0500 Fixed java structure-template typo and pinned the aliases
=("java" . "src javas")= expanded to a bogus =#+begin_src javas=; corrected it to =src java=. Added =test-org-babel-config-structure-templates.el=, which requires the module then org-tempo (firing the deferred :config) and asserts =bash=, =zsh=, =el=, =py=, =json=, =yaml=, =java= each map to the intended src language. Fixed in the org-babel commit.
**** TODO [#B] Make org-contacts/Mu4e boundaries explicit :cleanup:refactor:
=org-contacts-config.el= defines helpers that call Mu4e functions when the
current major mode is a Mu4e mode, and the =use-package org-contacts= block is
=:after (org mu4e)= while also requiring =mu4e= inside =:config=. This works in
the current eager setup, but the ownership boundary is unclear now that
=mu4e-org-contacts-integration.el= exists.
Expected outcome:
- Decide whether contact capture-from-email behavior belongs in
=org-contacts-config.el= or the Mu4e integration modules.
- Add =declare-function= / autoloads or move Mu4e-specific code behind
=with-eval-after-load 'mu4e=.
- Keep plain Org contact commands usable on systems without Mu4e loaded.
- Add a smoke test for loading =org-contacts-config.el= without Mu4e stubs if
practical.
**** TODO [#B] Add an Org workflow health check command :feature:ux:solo:discuss:
Several Org workflow modules depend on personal paths, optional external tools,
and local package checkouts. Failures currently show up at command time in
different ways, depending on which module hits the missing dependency first.
Recommended improvement:
- Add a lightweight =cj/org-workflow-doctor= command that checks the main Org
workflow prerequisites without mutating user data.
- Report status for core files/directories: =org-dir=, =roam-dir=, =drill-dir=,
=contacts-file=, =webclipped-file=, =cj/hugo-content-org-dir=, and
=cj/reveal-root=.
- Report optional executable/package availability for Pandoc/org-web-tools,
Hugo, reveal.js, org-drill, org-roam, and org-noter.
- Keep startup quiet; run this only on demand.
- Make the checker return structured data so it can be unit-tested and displayed
either in Messages or a buffer.
**** 2026-05-24 Sun @ 14:43:13 -0500 Added capture-template key + target smoke tests
New =test-org-capture-templates-integrity.el= loads the cleanly-loadable capture modules (=org-capture-config=, =quick-video-capture=, =org-contacts-config=), applies their lazy additions, and asserts no two templates share a dispatch key and that every symbol-valued file target resolves to a non-empty path string. Literal-string targets (the video template's no-save =(file "")=) and lambda targets (drill file pickers) are excluded. Webclipper templates need org-web-tools at registration time, so they stay covered by their own test rather than this batch smoke test. Mutation-checked that the uniqueness assertion flags a duplicate key. Commit =2e3905c7=.
**** TODO [#B] Document Org workflow module ownership and load boundaries :docs:refactor:solo:discuss:
The Org workflow is spread across many modules with overlapping responsibilities:
capture templates, keymaps, org-protocol handlers, refile/agenda target
construction, roam notes, publishing, and document annotation. The code is
usable, but future load-order work will be easier with explicit ownership notes.
Recommended improvement:
- Add a short design note under =docs/design/= that maps each Org module to the
behavior it owns.
- Call out which modules may mutate global Org variables, capture templates,
keymaps, and protocol handlers.
- Define which modules should be safe to load in batch mode and which are
allowed to start timers or require interactive packages.
- Link this note from the Org workflow review task and the broader load-graph
refactor.
**** 2026-05-16 Sat @ 03:44:45 -0500 Removed duplicate org-protocol-protocol-alist registration in cj/webclipper-ensure-initialized
The =webclip= protocol handler is registered twice:
=modules/org-webclipper.el:72-76= inside =cj/webclipper-ensure-initialized=
(unconditional =add-to-list=) and =:207-214= inside a
=with-eval-after-load 'org-protocol= block (=unless (assoc ...)= guard).
=add-to-list= uses =equal= membership so the two are effectively
idempotent, but maintaining two registration paths invites drift if
the alist entry shape ever changes. Pick one site -- the
=with-eval-after-load= block is the more robust location -- and
remove the other.
**** 2026-05-16 Sat @ 03:44:45 -0500 Validated :url and :title in cj/org-protocol-webclip before stashing
=modules/org-webclipper.el:124-125= extracts =:url= and =:title= from
the incoming protocol plist with no type or nil check. An unexpected
plist shape silently sets the globals to nil, and downstream code
fails inside the capture handler with confusing messages. Guard with
=(unless (and (stringp url) (not (string-empty-p url))) (user-error ...))=
before stashing.
**** 2026-05-24 Sun @ 07:26:31 -0500 Scoped webclip URL/title to dynamic bindings
The protocol handler =setq= globals =cj/webclip-current-url= / =cj/webclip-current-title= that the "W" template and handler read (and cleared), so an aborted/erroring capture left stale state for the next clip. Renamed to =cj/--webclip-url= / =cj/--webclip-title= and =let=-bind them around the =org-capture= call: the template =%(identity ...)= forms and the handler run within that dynamic extent, and an abort/error unwinds the binding automatically — no stale state, no manual clear. Mirrors the quick-video-capture fix. Tests updated to the new contract (visible-during-capture, nothing-left-after, aborted-leaves-nothing). Commit =6dfc41af=.
**** 2026-05-16 Sat @ 03:44:45 -0500 Declared cross-module free vars in mu4e-org-contacts-integration.el
The module reads =contacts-file= (defined in =user-constants.el=) and
calls =cj/get-all-contact-emails= (defined in =org-contacts-config.el=)
without any forward declaration at the top of the file. Byte-compile
in isolation warns about both as free variables / unknown functions.
Add =(eval-when-compile (defvar contacts-file))= and
=(declare-function cj/get-all-contact-emails "org-contacts-config")=
near the existing requires so the compile is clean and the
cross-module dependency is explicit at the top of the file.
**** 2026-05-25 Mon @ 17:10:47 -0500 Added mu4e org-contacts completion coverage
Added =tests/test-mu4e-org-contacts-integration.el= (10 tests). The capf
(cj/org-contacts-completion-at-point) is checked for the header-field and
compose-mode gating both ways, the bounds/table it returns when contacts
exist, and the empty-contacts case. TAB (cj/mu4e-org-contacts-tab-complete)
is checked across all three branches: completion-at-point in a header,
org-cycle in the org-msg body, indent elsewhere. Comma completion and the
direct-insert no-op-outside-header path round it out. mail-abbrev-in-expansion-header-p,
the mode actions, and cj/get-all-contact-emails are stubbed, so the run is
headless with no mu4e/org-contacts dependency.
*** DOING [#B] Harden programming workflow modules :harden:
Scope:
- =prog-c.el=
- =prog-general.el=
- =prog-go.el=
- =prog-json.el=
- =prog-lisp.el=
- =prog-lsp.el=
- =prog-python.el=
- =prog-shell.el=
- =prog-training.el=
- =prog-webdev.el=
- =prog-yaml.el=
- =coverage-core.el=
- =coverage-elisp.el=
- =test-runner.el=
- =vc-config.el=
- =keyboard-compat.el=
- =dev-fkeys.el=
Review progress:
- Reviewed 2026-05-03 at high level.
- =dev-fkeys.el= reviewed 2026-05-03 after local edits settled. The focused
dev-fkeys test set passed: 22 test files, 163 ERT tests.
- =coverage-core.el= / =coverage-elisp.el= have strong pure-helper tests.
- Language formatter wiring is covered for Python, Go, shell, webdev, JSON, and
YAML.
- =test-runner.el= has direct tests, but project-scoping is still a design gap.
Completion review 2026-05-15:
- Re-checked the scoped programming workflow modules and current tests.
- =dev-fkeys.el=, coverage modules, formatter wiring, keyboard compatibility,
and test-runner helpers have meaningful focused coverage.
- Remaining issues are logged below: F4 project capability classification,
LSP ownership and smoke coverage, tree-sitter auto-install policy, Git clone
process handling, shell-script executable policy, and formatter process
boundaries.
**** 2026-05-25 Mon @ 17:35:02 -0500 Added prog-lisp smoke coverage; assessed the other two
Added =tests/test-prog-lisp.el= (4 tests) covering the config prog-lisp owns
directly: cj/elisp-setup and cj/common-lisp-setup are each registered on their
mode hook and apply the right buffer locals (4-space/no-tabs/fill-120 for
elisp, 2-space/fill-100 for Common Lisp). The module loads with use-package
stubbed to a no-op, so nothing installs or downloads in batch.
=prog-training.el= got no test: it's entirely deferred use-package config with
no top-level surface, and its only owned settings (leetcode language/dir) live
in a deferred =:config= that would make the test package-dependent for no real
value. Forcing a test there would be coverage theater.
=prog-general.el= is deferred: it's the one of the three that touches LSP and
tree-sitter, and the task itself says to wait for the LSP/tree-sitter policy
tasks to land before fixing its assertions. Its smoke coverage rides with those.
**** TODO [#B] Revisit F4 project classification vs actual project capabilities :ux:
=dev-fkeys.el= classifies a project as =interpreted= if it has
=pyproject.toml=, =requirements.txt=, =Pipfile=, or =package.json=, even when it
also has a =Makefile=. That intentionally keeps Python/Node projects on a
Run-only F4 menu, but it also hides useful Compile/Clean options for projects
where =Makefile=, =package.json= scripts, or Projectile cached commands provide
real build/test tasks.
Expected outcome:
- Decide whether F4 should classify by language family or by available
capabilities.
- Consider deriving candidates from Projectile's known compile/run/test commands
first, then falling back to markers.
- Keep the current "interpreted markers win" behavior only if that remains the
intentional UX after trying it in mixed Python/Node projects.
**** PROJECT [#B] Consolidate LSP ownership across programming modules :architecture:refactor:
LSP setup is currently split across =prog-general.el=, =prog-lsp.el=, and each
language module. There are multiple =use-package lsp-mode= forms and some
conflicting defaults:
- =prog-general.el= enables snippets/UI doc/sideline behavior.
- =prog-lsp.el= disables snippets/UI doc/sideline-heavy behavior.
- Python, Go, shell, C, and webdev modules both call =lsp-deferred= from local
setup functions and add package hooks that call =lsp-deferred= again.
This probably works because lsp-mode is defensive, but it makes the final
runtime policy hard to predict.
***** TODO [#B] Make =prog-lsp.el= the single owner of generic LSP policy :refactor:
Expected outcome:
- Move generic =lsp-mode= and =lsp-ui= defaults out of =prog-general.el=.
- Keep language-specific server variables in language modules.
- Keep one hook path per language for starting LSP.
- Preserve the remote-file guard.
Pitfalls:
- =lsp-pyright= may still need a language-specific hook to load before LSP
starts.
- Do not accidentally re-enable UI/doc/sideline behavior that was explicitly
disabled for performance.
***** TODO [#B] Add a startup smoke test for LSP config resolution :solo:
Keep this narrow. A useful test can require the LSP-related modules with mocked
=use-package= side effects and assert that:
- generic defaults are set in one place,
- no duplicate hook entries are installed for the same mode,
- =lsp-enable-remote= remains nil.
**** TODO [#B] Gate tree-sitter grammar auto-install behind an explicit policy :startup:
=prog-general.el= sets =treesit-auto-install= to =t=. That means opening a file
can trigger grammar download/build/install behavior. This is convenient on a
fresh machine, but it is a startup/network/build side effect in normal editing
and batch contexts.
Expected outcome:
- Prefer ='prompt= or a custom command such as =cj/install-treesit-grammars=.
- Batch/test startup should never auto-install grammars.
- Document the intentional bootstrap path for a new machine.
Originally meant to coordinate with the [#A] Python tree-sitter predicate
syntax issue. That one resolved upstream on 2026-05-14 (see =docs/python-
treesit-predicate-mismatch.txt= RESOLVED footer), so this task no longer
depends on it.
**** 2026-05-24 Sun @ 04:30:14 -0500 Hardened clipboard git-clone process and path handling
=cj/git-clone-clipboard-url= shelled out via =shell-command= and derived the dir with =file-name-nondirectory=, which mishandled scp-style SSH with no slash (=git@host:repo.git= → =git@host:repo=) and silently did nothing on a failed clone. Now clones as a direct =git= process (=call-process=, no shell) with =clone -- url dir= (so a =-=-leading URL can't be read as a flag); the destination comes from =cj/--git-clone-dir-name= (last component split on =/= and =:=, handling HTTPS, scp/ssh:// SSH, local paths); validates non-empty clipboard + writable target dir + non-existing destination; surfaces a non-zero git exit as a =user-error= with the =*git-clone*= output. Tests cover the deriver across schemes + empty-clipboard + clone-failure. Commit =35e4d701=.
**** TODO [#B] Decide whether auto-executable shell scripts should be opt-in :ux:
=prog-shell.el= adds a global =after-save-hook= that sets executable bits on any
saved file with a shebang. This is convenient, but it silently changes file
modes for every buffer in the session.
Expected outcome:
- Decide whether this should remain global, be limited to shell/script modes, or
prompt the first time per file.
- Preserve the fast path for real scripts.
- Keep the existing =cj/make-script-executable= tests updated for the chosen
policy.
**** 2026-05-25 Mon @ 18:05:56 -0500 Moved JSON/YAML/webdev formatters to argv process calls
Replaced =shell-command-on-region= with =call-process-region= (explicit program
+ argv, no shell) in cj/json-format-buffer (jq), cj/yaml-format-buffer
(prettier), and cj/webdev-format-buffer (prettier). The webdev path dropped its
=shell-quote-argument= once the filename became a plain argv element. Point
preservation is unchanged. One deliberate, tested improvement: the old code
clobbered the buffer with the formatter's error text on a non-zero exit; the new
per-module =cj/---format-region= helper captures output, checks the exit
code, replaces only on success, and otherwise raises a user-error with stderr.
Kept a small helper in each of the three modules rather than one shared one,
since they share no module short of system-lib. Added argv-invocation and
no-clobber tests per formatter; existing wiring tests stay green. Done by
subagent, reviewed.
**** 2026-05-16 Sat @ 03:54:56 -0500 Added cj/executable-find-or-warn checks for prettier and pyright at load time
=modules/prog-webdev.el:34-40= declares =prettier-path= and
=modules/prog-python.el:33-40= declares =pyright-path= as string
literals. No validation at module load means a missing executable
surfaces only at first use -- format-on-save fires, then errors
mid-edit, then the user has to discover why. Wrap with
=cj/executable-find-or-warn= (already in =system-lib.el=) at module
load time so the missing dependency is reported up front.
**** 2026-05-16 Sat @ 03:54:56 -0500 Documented keyboard-compat hook idempotence (already correct)
=modules/keyboard-compat.el:169-174= adds the frame-setup hook
unconditionally. If the module is required twice (e.g. via two
=eval-after-load= chains in different load orders), the hook runs
twice per new frame and installs duplicate =key-translation-map=
entries. Wrap the =add-hook= in a guard, or use a named function
and rely on =add-hook='s own duplicate-check.
**** 2026-05-16 Sat @ 03:54:56 -0500 Wired F6 TypeScript clause to npx vitest|jest
=modules/dev-fkeys.el:261-269= maps =tsx= to =typescript= in the
language detection table. =modules/dev-fkeys.el:347-349=
(=cj/--f6-test-runner-cmd-for=) has no clause for =typescript= --
the catch-all =(_)= returns nil, so F6 errors instead of routing to
a real runner. Either add a =typescript= → =jest=/=vitest= clause
or remove the =tsx= mapping until the runner side is implemented.
**** 2026-05-16 Sat @ 03:54:56 -0500 Fixed prog-lsp eldoc-provider removal to act on the global hook
=modules/prog-lsp.el:51-54,76= attaches
=cj/lsp--remove-eldoc-provider= globally to =lsp-managed-mode-hook=
but the removal it performs uses =(remove-hook ... t)= -- a
buffer-local removal. The first LSP buffer activates the hook,
which removes the provider for that buffer. Subsequent LSP buffers
still inherit the global default because the hook itself never
re-fires the buffer-local removal in their context. Either make
the hook itself buffer-local-friendly (add it inside
=lsp-managed-mode-hook= per-buffer) or remove from the global
provider list once instead of per-buffer.
**** 2026-05-16 Sat @ 03:54:56 -0500 Externalized LanguageTool script path via user-emacs-directory
=modules/flycheck-config.el:69= hardcodes
=~/.emacs.d/scripts/languagetool-flycheck= as the LanguageTool wrapper.
Users running from a non-standard =user-emacs-directory= (or anyone
auditing the module against a future package) get a broken checker.
Replace with =(expand-file-name "scripts/languagetool-flycheck"
user-emacs-directory)= or a defcustom.
**** 2026-05-16 Sat @ 03:54:56 -0500 Replaced hardcoded Zathura viewer with executable-find candidate list
=modules/latex-config.el:28= sets the LaTeX viewer to =zathura=
unconditionally. macOS / Windows users and anyone who prefers a
different PDF viewer lose document review without explanation.
Resolve via =executable-find= over a candidate list (=zathura=,
=evince=, =okular=, =Preview.app= via =open=, =SumatraPDF.exe=) and
fall back to =pdf-tools=.
**** 2026-05-15 Fri @ 18:49:24 -0500 Fixed abbrev-mode no-arg toggle in =cj/prose-helpers-on=
Replaced the =(if (not (abbrev-mode)) (abbrev-mode))= shape with
=(unless (bound-and-true-p abbrev-mode) (abbrev-mode 1))= and the same
for =flycheck-mode= in =modules/flycheck-config.el:36-42=. Dropped
"flyspell" from the docstring (the commented-out
=cj/flyspell-on-for-buffer-type= line had been gone for a while; the
docstring was lying) and removed the stale comment marker.
Added =tests/test-flycheck-config-prose-helpers-on.el= -- 4 tests
covering Normal (both modes off -> each enabled once with a positive
arg) and Boundary (both on -> no-op; each mixed state -> only the off
one enabled). The "both on -> no-op" assertion is the regression
guard for the no-arg toggle shape: it would record a =(nil)= call list
under the bug and a =()= call list under the fix.
Test infra needed an explicit =(defvar abbrev-mode)= /
=(defvar flycheck-mode)= at the top of the test file: with
=lexical-binding: t= and flycheck loaded =:defer t=, =let= on the
flycheck-mode symbol creates a lexical-only binding the production
code's =bound-and-true-p= can't see; declaring both as special makes
=let= dynamic and the test stable.
Full suite: =make test= exits 0; 468 lines of output with =ALL UNIT
TESTS PASSED= banner; no regressions.
*** DOING [#B] Harden integrations and application modules :harden:
Scope:
- AI/rest: =ai-config.el=, =ai-conversations.el=, =restclient-config.el=
- Mail/chat/social: =mail-config.el=, =mu4e-*.el=, =slack-config.el=,
=erc-config.el=, =elfeed-config.el=, =eww-config.el=
- File/media/apps: =dirvish-config.el=, =dwim-shell-config.el=, =pdf-config.el=,
=calibredb-epub-config.el=, =music-config.el=, =quick-video-capture.el=,
=video-audio-recording.el=, =transcription-config.el=
- Utilities/apps: =auth-config.el=, =browser-config.el=, =dashboard-config.el=,
=help-config.el=, =help-utils.el=, =jumper.el=, =keyboard-macros.el=,
=local-repository.el=, =lorem-optimum.el=, =reconcile-open-repos.el=,
=show-kill-ring.el=, =system-commands.el=, =system-utils.el=,
=tramp-config.el=, =undead-buffers.el=, =weather-config.el=, =wrap-up.el=
Review progress:
- Reviewed 2026-05-03 at high level by direct reads plus risky-pattern search.
- Recording/transcription and music modules have much stronger coverage than
most application wrappers.
- Existing coverage audit already tracks =ai-conversations=, =quick-video-capture=,
=dashboard-config=, =mail-config=, =show-kill-ring=, =system-commands=, and
=wrap-up= as high-value test targets.
Completion review 2026-05-15:
- Re-checked the scoped integration/application modules with risky-pattern and
test-coverage searches.
- Many integration modules now have focused tests, including AI config,
restclient, mail helpers, Slack commands, Dirvish helpers, music,
recording/transcription, system commands, browser/help/jumper/reconcile, and
undead buffer helpers.
- Remaining issues are already logged below, especially system command safety,
REST key persistence, mail privacy/lifecycle policy, quick-video timers and
temp state, shell-heavy dwim/recording command hardening, AI conversation
persistence coverage, calendar operational behavior, Dirvish dependency/path
hardening, EWW/Elfeed network helpers, and Slack which-key registration.
**** 2026-05-24 Sun @ 04:15:36 -0500 Made Emacs restart and destructive confirms defensive
Restart-Emacs scheduled an unconditional =kill-emacs= one second after firing the systemctl restart, so a missing or failed service killed the session with nothing to replace it. Restart now guards on =(daemonp)= and a present =emacs.service= (new =cj/system-cmd--emacs-service-available-p= via =systemctl --user cat=) before doing anything, and drops the separate =kill-emacs= — =systemctl restart= cycles the daemon itself, so a failed restart leaves the current Emacs alive. Shutdown and reboot moved to a strong =yes-or-no-p= confirm (a stray RET/space on the old quick prompt could power off the machine); logout and suspend keep the quick confirm since they are recoverable. Tests cover service detection, both restart guards, and the strong-confirm paths with system primitives stubbed. Commit =f1dbec16=.
Not done: the detached restart+reconnect (=nohup sh -c '... && emacsclient -c'=) may still race systemd's cgroup teardown of =emacs.service= before =emacsclient -c= runs. Couldn't verify from here without cycling the live daemon — eyeball the reconnect on the next real restart.
**** 2026-05-23 Sat @ 19:01:53 -0500 Removed SkyFi key-injection feature from restclient-config
Resolved by removing the feature rather than hardening it. =cj/restclient-skyfi-buffer= opened =data/skyfi-api.rest= in a file-visiting buffer and rewrote the =:skyfi-key= line with the real key from authinfo, so an accidental save would persist the key to local disk (the file was gitignored and never tracked, so no repo/public-mirror exposure — local plaintext only). Deleted =cj/skyfi-api-key=, =cj/restclient--inject-skyfi-key=, =cj/restclient-skyfi-buffer=, the =C-; R s= binding, the two SkyFi test files, and the local =data/skyfi-api.rest= template. Generic restclient (=C-; R n=, =C-; R o=, restclient/restclient-jq) kept.
**** TODO [#B] Reconcile mail image/privacy settings :privacy:
=mail-config.el= documents blocked remote images and sets
=gnus-blocked-images=, but later enables both =mu4e-show-images= and
=mu4e-view-show-images=. The interactive toggle changes =gnus-blocked-images=
buffer-locally, so the final privacy behavior is hard to reason about without
manual testing against real HTML messages.
Expected outcome:
- Decide the default policy for embedded images versus remote HTTP images.
- Make the toggle report the effective state in the current mu4e view buffer.
- Add a short manual checklist or mocked test for the variables that control
remote image display.
**** 2026-05-23 Sat @ 03:52:00 -0500 Set compose buffers to kill on exit, both composers
First clarified the ownership (dd671f8c): the org-msg comment "always kill buffers on exit" was backwards — org-msg set =nil= (keep), which won over mu4e's =t= because org-msg-mode runs in every compose buffer. Craig then chose to kill compose buffers on exit, so I set the org-msg value to =t= as well (82978c79). Both mu4e and org-msg now kill the buffer on send/exit, so HTML drafts don't linger.
**** 2026-05-24 Sun @ 07:26:31 -0500 Dropped startup timers for lazy protocol init
=quick-video-capture.el= scheduled an =after-init-hook= idle timer + a 2s fallback =run-with-timer= to call setup, which required org-protocol/capture and registered both the protocol handler and the capture template at every startup. Split the concerns like =org-webclipper.el=: the org-protocol handler registers in a =with-eval-after-load 'org-protocol= block (lightweight =add-to-list=, in place whenever org-protocol loads — org-config requires it at startup), and =cj/setup-video-download= now registers only the capture template lazily (first capture or first protocol call). Both timers gone. Tests pin that setup registers the template idempotently and no longer touches the protocol alist; verified in a live daemon that the protocol registers on load. Commit =bc965275=.
**** 2026-05-24 Sun @ 04:30:14 -0500 Scoped video-capture URL to a dynamic binding
The protocol handler =setq= a global =cj/video-download-current-url= and the capture handler read/cleared it, so an aborted or erroring capture left the stale URL for the next manual capture. Renamed to =cj/--video-download-url= and =let=-bind it around the =org-capture= call instead of mutating a global: the binding lives only for the capture's dynamic extent, so the handler sees the URL while the capture runs and an abort/error unwinds it automatically — no stale state, no manual clear. Manual invocation still prompts. Tests cover bound-URL download, manual prompt, empty-URL error, URL-visible-during-capture, and aborted-capture-leaves-nothing. Commit =b26b74cb=.
Note: the sibling =org-webclipper.el= still uses the same global-mutation pattern (=cj/webclip-current-url= / =title=); a separate =:solo:= task tracks that.
**** TODO [#B] Audit shell-command-heavy recording and dwim-shell workflows :security:refactor:
=video-audio-recording.el= and =dwim-shell-config.el= are intentionally close to
the shell: pactl/ffmpeg/qpdf/7z/tesseract/media conversion commands are the
point. They also have the highest process and quoting surface in the config.
Expected outcome:
- Keep the current workflows, but catalog which commands accept filenames,
URLs, passwords, or free-form user input.
- Prefer argv process APIs for commands that do not require a shell.
- For commands that must use shell templates, document which placeholders are
safely quoted by =dwim-shell-command= and add focused tests around password
temp-file cleanup.
***** 2026-05-23 Sat @ 19:11:30 -0500 Fixed async password temp-file lifetime in dwim-shell
The four password commands (PDF protect/unprotect, remove-zip-encryption, create-encrypted-zip) deleted the password temp file in =unwind-protect= the instant the async command launched, so =qpdf=/=7z= could start after the file was gone. Extracted =cj/dwim-shell--run-with-password-file= + =cj/dwim-shell--password-cleanup-callback=: the temp file (mode 600) is now deleted from an =:on-completion= callback that fires after the process exits (success or failure), with the synchronous =unwind-protect= kept only as a pre-launch-failure backstop. Rewrote all four commands onto the helper. 5 ERT tests cover the cleanup callback (success/error/missing-file) and the runner (writes 600 file + defers cleanup; cleans up on launch failure). qpdf already passes the password via =--password-file= (out of argv); the 7z argv exposure is split into its own follow-up below.
***** 2026-05-24 Sun @ 04:20:31 -0500 Accepted the brief 7z password-on-argv exposure, documented it
Investigated whether 7z could take the password off argv. It can't: 7-Zip 26.01 reads the password only from its controlling TTY, not stdin or a file. Verified empirically — =printf pw | 7z a -p ...= silently created an archive with an *empty* password (the piped value never reaches it), and a round-trip with the same password failed. So the password must go on argv via =$(cat tempfile)= and is briefly visible in the process list while 7z runs.
Craig's call (2026-05-24): accept the exposure rather than switch off the .7z format. On a single-user workstation, for a short-lived process, with the password already kept out of shell history by the mode-600 temp file, the residual exposure is acceptable. The gpg-wrapped-tar alternative would close it but change the archive format and decrypt workflow. Recorded the tradeoff in both function docstrings in =dwim-shell-config.el= so the decision is visible at the call site, not just here.
***** 2026-05-23 Sat @ 19:18:00 -0500 Quoted/validated user-controlled dwim-shell inputs
Closed the four injection-quoting cases. git-clone-clipboard-url now validates the clipboard with =cj/dwim-shell--valid-git-url-p= and passes the URL via =shell-quote-argument= instead of the raw =<>= substitution. GPG recipient and the 7z archive name go through =shell-quote-argument= instead of hand-written single quotes. The ffmpeg thumbnail timestamp is validated with =cj/dwim-shell--valid-ffmpeg-timestamp-p= (digits/colons/dot only) before it reaches =-ss=. The sequential-rename prefix is validated filename-safe with =cj/dwim-shell--safe-rename-prefix-p=. 7 ERT tests cover the three validators (Normal/Boundary/Error); the two =shell-quote-argument= swaps trust the builtin. The fifth case — video concatenation's echo/tr/sed filelist — is a redesign rather than a quoting fix and is split out below.
***** 2026-05-23 Sat @ 19:58:00 -0500 Rebuilt video-concat filelist in Elisp
=cj/dwim-shell-commands-concatenate-videos= built the ffmpeg concat list with =echo '<<*>>' | tr ' ' '\n' | sed 's/^/file /'=, which split on spaces and broke on quotes. Extracted =cj/dwim-shell--build-concat-filelist=, which renders each path as an escaped =file '...'= line (single quotes escaped as ='\''=), writes it to a temp file in Elisp, and runs =ffmpeg -f concat -i = with a trailing =; rm -f= to clean up after the process exits. =<<*>>= stays only as an inert trailing shell comment so dwim-shell still runs one command over all marked files. 3 ERT tests cover plain paths, spaces, and an embedded quote.
***** 2026-05-23 Sat @ 20:17:00 -0500 Clarified broad/misleading file-operation commands
=remove-empty-directories= ran =find . -type d -empty -delete= from the ambient current directory. Now it prompts for an explicit root (via =read-directory-name=), names that root in the confirmation, and runs =find ...= built by =cj/dwim-shell--empty-dirs-command= (2 ERT tests cover the command shape + space quoting). =secure-delete= called =shred= without =-u=, overwriting file contents but leaving the file in place despite the name and the "permanently destroy" prompt; added =-u= (=shred -vfzu=) so it actually unlinks. Both use the inert =<<*>>= comment trick for single-execution.
***** 2026-05-24 Sun @ 04:10:20 -0500 Shell-quoted X11 and audio recording command paths
The Wayland =wf-recorder= path already quoted its args, but the X11 =ffmpeg= path and the audio-only =ffmpeg= path interpolated mic device, system device, and output filename raw — breaking on directories with spaces or unusual device names. Wrapped all three in =shell-quote-argument= on both paths. Extracted =cj/recording--build-audio-command= (mirroring =cj/recording--build-video-command=) so the audio command is unit-testable, then quoted there. Tests cover device names and filenames with spaces on both builders. Commit =39795e85=.
***** 2026-05-24 Sun @ 04:10:20 -0500 Scoped wf-recorder stop signal to our own process
Stop ran =pkill -INT wf-recorder=, signalling every wf-recorder on the system including an unrelated screen capture. Added =cj/recording--interrupt-child-wf-recorder=, which scopes the producer-first interrupt to the wf-recorder child of our own recording shell via =pkill -P =. Producer-first ordering preserved (ffmpeg still sees a clean pipe EOF). The orphan-cleanup at recording start stays a broad by-name kill on purpose — those leftovers come from crashed sessions whose shells are already dead, so there is no live PID to scope to. Tests cover the scoped call, the nil-PID no-op, and that the bare system-wide form is never used. Commit =556f48a2=.
***** 2026-05-24 Sun @ 04:10:20 -0500 Created the selected recording directory, not its parent
The toggles ran =(file-name-directory location)= before =make-directory=, which returns the *parent* for a path without a trailing slash — so the selected directory went uncreated and ffmpeg failed to write into it. Both toggles now route the destination through =cj/recording--normalize-recording-dir= (expand + =file-name-as-directory=) and =make-directory= that, creating the selected directory itself (including names with spaces). Tests cover trailing-slash normalization, idempotence, spaces, and relative-to-absolute expansion. Commit =dc033c75=.
**** TODO [#B] Make AI conversation persistence path-safe and project-aware :cleanup:refactor:
=ai-conversations.el= has good pure helper seams but is currently untested in
this repo. The path slugging is simple and the save/load/delete commands operate
directly in a single global directory.
Expected outcome:
- Add tests for candidate sorting, topic slug collisions, autosave path setup,
and delete confirmation behavior.
- Consider whether conversations should remain global or support project-scoped
subdirectories.
- Confirm autosave never writes partial prompt/response state to an unexpected
file after loading a different conversation.
**** TODO [#B] Harden calendar sync operational behavior around the parser :data:refactor:
=calendar-sync.el= has broad parser/recurrence coverage, but the operational
path around it still has startup, persistence, and fetch risks.
Expected outcome:
- Move private calendar URLs out of source and rotate the exposed feed URLs
before doing further cleanup.
- Avoid immediate network fetches at module load unless explicitly enabled for
interactive sessions.
- Add a per-calendar in-flight guard so a timer tick cannot launch overlapping
syncs for the same calendar.
- Use =curl --fail= or equivalent status handling so HTTP error pages are not
treated as successful ICS downloads.
- Write generated Org files atomically via a temp file and rename.
- Read the local state file with =read-eval= disabled.
**** 2026-05-23 Sat @ 04:18:44 -0500 AI conversation persistence coverage already in place
Premise was stale. =tests/test-ai-conversations.el= (47 cases) already covers slug generation, timestamp parsing, candidate sorting (newest/oldest), latest-file selection, save/load header stripping against a temp dir, autosave path/timer, and delete confirmation — with GPTel stubbed throughout. Acceptance list satisfied; no new file needed.
**** 2026-05-23 Sat @ 03:31:12 -0500 Dirvish helper coverage already in place
The task premise was stale: =dirvish-config.el= now has 14 focused test files (=test-dirvish-config-*.el=, ~100 cases) covering duplicate-naming, resolve-display-path (project/home/absolute/org-link), ediff-pair, html/printable predicates, playlist filtering/sanitizing, wallpaper-program mapping, and the public wrappers. The remaining gaps (playlist name-safety, set-wallpaper nil-file) were filled by the L2668 hardening commit 8fc6432d. No new file needed.
**** 2026-05-23 Sat @ 03:21:12 -0500 Declared dirvish-config runtime deps with plain require
Switched =user-constants= and =system-utils= from =eval-when-compile= to plain =require= in =dirvish-config.el=, matching the three sibling requires below. The module builds =dirvish-quick-access-entries= from =code-dir=/=music-dir=/=pix-dir= at load and binds keys to =cj/xdg-open=/=cj/open-file-with-command=, so the deps are genuine runtime inputs. Added =tests/test-dirvish-config-runtime-requires.el= as a dependency-contract smoke test. Fixed in b63c4f83.
**** 2026-05-23 Sat @ 03:31:12 -0500 Hardened dirvish wallpaper + playlist path helpers
=cj/set-wallpaper= passed =(dired-file-name-at-point)= straight into =expand-file-name=, so no-file-at-point raised a bare wrong-type-argument; added a nil guard that signals a clear user-error. =cj/dired-create-playlist-from-marked= expanded a raw name under =music-dir= with no check; added =cj/--playlist-name-safe-p= to reject any name carrying a directory separator (=../=, absolute, nested) before the path is built. Regression tests went into =test-dirvish-config-wrappers.el= and =test-dirvish-config-playlist.el=. The duplicate/copy-path helpers already guarded nil, so they were left alone. Fixed in 8fc6432d.
**** 2026-05-23 Sat @ 03:38:30 -0500 Coverage already in place for mail + system-commands
The task premise was stale. =mail-config.el= has =test-mail-config-helpers.el= (4), =test-mail-config-transport.el= (7), and =test-mail-config.el= (1) covering executable discovery and transport command assignment. =system-commands.el= has =test-system-commands-keymap.el= (2, keymap shape + candidates) and =test-system-commands-resolve-and-run.el= (13, confirmation routing + command-string construction with shell-command stubbed). Both acceptance lists are satisfied; no new tests needed.
**** 2026-05-24 Sun @ 14:43:13 -0500 Bounded the elfeed YouTube fetch + locked EWW UA scoping
=cj/youtube-to-elfeed-feed-format= called =url-retrieve-synchronously= with no timeout (a hung request blocks Emacs) and only killed the temp URL buffer when an ID was extracted, leaking it on the parse-failure path. Passed =cj/elfeed-url-fetch-timeout= (10s) and moved fetch+parse into an =unwind-protect= that always kills the buffer. The EWW user-agent advice (=eww-config.el=) was already correctly scoped — it injects the UA only from eww-mode buffers, so package.el and other non-EWW url callers pass through untouched — so no code change there, just tests pinning that scoping and the replace-not-duplicate header behavior. Commit =c097b5b4=.
**** 2026-05-16 Sat @ 04:00:00 -0500 Moved Slack which-key registration behind with-eval-after-load
=slack-config.el= calls =which-key-add-keymap-based-replacements= at top level,
while most modules defer which-key registration. If which-key is not loaded or
autoloaded as expected, Slack config can fail during require.
Expected outcome:
- Wrap the registration in =with-eval-after-load 'which-key=.
- Add a module-load smoke test or byte-compile check if easy.
**** 2026-05-16 Sat @ 04:00:00 -0500 Removed httpd-start side effect from markdown-preview
=modules/markdown-config.el:37-51= starts =simple-httpd= inside an
interactive command, then opens a browser at
=http://localhost:8080/imp=. Starting a network listener as a side
effect of a "preview" command surprises users; once started, the
server keeps running until Emacs exits. Either gate the start
behind an explicit confirmation, document the listener clearly, or
move the server start into a separate =cj/markdown-preview-server-start=
command so =markdown-preview= just opens the URL once the server is
known to be running.
**** 2026-05-25 Mon @ 18:29:40 -0500 Moved eshell SSH hosts into a defcustom
Replaced the three inline =eshell/alias= SSH-jump lines with a
=cj/eshell-ssh-hosts= defcustom (an alias→remote-path alist defaulting to the
current gocj/gosb/gowolf entries) that a per-machine config can override or set
to nil. The aliases are now built by iterating it via
=cj/--eshell-define-ssh-aliases=; the pure =cj/--eshell-ssh-alias-commands=
helper makes the construction testable without a live eshell. Tests added in
=tests/test-eshell-config-ssh-aliases.el=.
**** 2026-05-16 Sat @ 04:00:00 -0500 Fixed https→http in markdown-preview + extracted server start
=modules/markdown-config.el:45= opens
=https://localhost:8080/imp= via =browse-url-generic=, but the
=simple-httpd= listener bound in =httpd-config.el= serves plain
HTTP on port 8080. The =https= scheme causes the browser to
attempt a TLS handshake against a plaintext listener -- the request
fails before any preview content reaches the page, so the entire
feature is broken end-to-end. Change the URL to
=http://localhost:8080/imp= (and consider switching the launch to
=browse-url= so the user's default protocol handler is respected).
**** TODO [#B] Document or vendor strapdown.js CDN dependency in =markdown-preview= :cleanup:solo:discuss:
=cj/markdown-html= (=modules/markdown-config.el:48-51=) embeds a
=