diff options
| author | Szymon Szukalski <szymon@szymonszukalski.com> | 2026-04-09 10:53:27 +1000 |
|---|---|---|
| committer | Szymon Szukalski <szymon@szymonszukalski.com> | 2026-04-09 10:53:27 +1000 |
| commit | bc75732b9d37b77945a977ee9f7892cf6efc79c3 (patch) | |
| tree | 4d9273ccc12c29eccc44fdc12372bea047414353 | |
| parent | 12a5b1464bb919ba23f2aa6c22d44de81e382151 (diff) | |
Refactor Emacs config into modules
| -rw-r--r-- | .gitignore | 10 | ||||
| -rw-r--r-- | AGENTS.md | 89 | ||||
| -rw-r--r-- | README.md | 408 | ||||
| -rw-r--r-- | abbrev_defs | 1 | ||||
| -rwxr-xr-x | build | 6 | ||||
| -rw-r--r-- | config.org | 1211 | ||||
| -rw-r--r-- | docs/plans/2026-04-09-modular-emacs-architecture-design.md | 81 | ||||
| -rw-r--r-- | docs/plans/2026-04-09-modular-emacs-refactor.md | 152 | ||||
| -rw-r--r-- | early-init.el | 14 | ||||
| -rw-r--r-- | init.el | 53 | ||||
| -rw-r--r-- | lisp/ss-agenda.el | 40 | ||||
| -rw-r--r-- | lisp/ss-capture.el | 175 | ||||
| -rw-r--r-- | lisp/ss-core.el | 147 | ||||
| -rw-r--r-- | lisp/ss-crm.el | 559 | ||||
| -rw-r--r-- | lisp/ss-denote.el | 49 | ||||
| -rw-r--r-- | lisp/ss-gptel.el | 21 | ||||
| -rw-r--r-- | lisp/ss-keys.el | 55 | ||||
| -rw-r--r-- | lisp/ss-org.el | 57 | ||||
| -rw-r--r-- | lisp/ss-ui.el | 135 | ||||
| -rwxr-xr-x | reset | 2 |
20 files changed, 1799 insertions, 1466 deletions
@@ -1,12 +1,16 @@ -# Keep the repository focused on the literate source and project docs. +# Keep the repository focused on the modular source and project docs. /* !/.gitignore !/AGENTS.md !/README.md -!/config.org +!/early-init.el +!/init.el !/abbrev_defs !/reset -!/build !/name-dictionary.el +!/lisp/ +!/lisp/** +/lisp/*.elc !/docs/ !/docs/plans/ +!/docs/plans/** @@ -2,60 +2,89 @@ ## Project Purpose -This repository is for the creation and maintenance of Emacs 30 configuration files. +This repository is for the creation and maintenance of Emacs 30 configuration +files. ## Repository Layout -- `config.org` is the single hand-edited source of truth for Emacs configuration. -- `init.el` and `early-init.el` are generated from `config.org` and should not be edited directly. -- Structure the literate config by startup responsibility first, then by workflow domain. -- The current Org workflow lives under `~/org/` with `journal.org`, `daily/`, `projects/`, `areas/`, `areas/people/people.org`, `resources/`, and `archives/`. +- `init.el` is the hand-edited runtime entry point. +- `early-init.el` is the hand-edited early startup file. +- Runtime implementation lives in domain modules under `lisp/`. +- Keep the module set reasonably chunky and domain-based: + `ss-core.el`, `ss-ui.el`, `ss-org.el`, `ss-agenda.el`, `ss-capture.el`, + `ss-denote.el`, `ss-crm.el`, `ss-gptel.el`, and `ss-keys.el`. +- The current Org workflow lives under `~/org/` with `journal.org`, `daily/`, + `projects/`, `areas/`, `areas/people/people.org`, `resources/`, and + `archives/`. - `~/org/` is external to this repository and must already exist. -- The configuration may open files in `~/org/`, but it must not create directories, create files, or validate note structure. -- `~/org/journal.org` is the operational journal. It must already exist. The configuration may open it, but it must not create or manage it. -- `~/org/moc.org` is a normal note. It must already exist. The configuration may open it, but it must not create or manage it. -- `~/org/areas/people/people.org` is the people CRM file. It must already exist. The configuration may open it, but it must not create or manage it. -- The operational journal lives in `~/org/journal.org`; older daily notes may remain under `~/org/daily/`; durable notes created with Denote live in PARA directories under `~/org/`. -- Agenda files are discovered by explicitly including `~/org/journal.org` and recursively scanning `.org` files under `~/org/projects/`, `~/org/areas/`, and `~/org/resources/`. +- The configuration may open files in `~/org`, but it must not create + directories, create files, or validate note structure. +- `~/org/journal.org` is the operational journal. It must already exist. The + configuration may open it, but it must not create or manage it. +- `~/org/moc.org` is a normal note. It must already exist. The configuration + may open it, but it must not create or manage it. +- `~/org/areas/people/people.org` is the people CRM file. It must already + exist. The configuration may open it, but it must not create or manage it. +- Agenda files are discovered by explicitly including `~/org/journal.org` and + recursively scanning `.org` files under `~/org/projects/`, `~/org/areas/`, + and `~/org/resources/`. - Agenda discovery must exclude `~/org/archives/`. -- PARA is the organising model for durable notes, and folder placement carries meaning. -- The config includes an experimental `gptel` setup that uses GitHub Copilot as the backend. +- PARA is the organising model for durable notes, and folder placement carries + meaning. +- The config includes an experimental `gptel` setup that uses GitHub Copilot + as the backend. - Do not treat `auto-save-list/` as source content. ## Editing Expectations - Prefer small, focused changes over broad rewrites. -- Edit `config.org` first, then regenerate the derived files instead of patching generated output by hand. +- Edit `init.el`, `early-init.el`, and the relevant `lisp/` modules directly. +- Keep `init.el` as the central composition layer with explicit feature + inclusion. +- Keep side effects out of require time where practical; modules should expose + setup functions. - Preserve existing Emacs Lisp style and naming where patterns already exist. - Avoid unrelated refactors while working on a specific configuration task. -- Keep narrative prose close to the configuration it explains, especially around the note-taking workflow and startup behavior. -- Be explicit about GUI-versus-terminal behavior. If a change affects `emacs -nw`, avoid moving terminal UI changes earlier in startup unless that timing is intentional. -- If the literate file starts to lose coherence, improve its sectioning and prose before introducing new generated helper files. +- Be explicit about GUI-versus-terminal behavior. If a change affects + `emacs -nw`, avoid moving terminal UI changes earlier in startup unless that + timing is intentional. ## Validation Expectations -- For Emacs Lisp changes, tangle `config.org` and verify the generated configuration loads cleanly before claiming completion. -- When Emacs is available, prefer a batch check such as `emacs --batch -Q --load init.el` from the repository root. -- Keep regression checks aligned with the generated startup path rather than any retired hand-maintained module layout. -- For changes that affect terminal Emacs behavior, verify in an actual `emacs -nw` session as well as batch mode; batch load alone will not catch interactive tty regressions. +- For Emacs Lisp changes, verify the hand-edited startup path directly. +- When Emacs is available, prefer a batch check such as + `emacs --batch -Q --load ./init.el` from the repository root. +- Keep regression checks aligned with the modular startup path documented in + `README.md`. +- For changes that affect terminal Emacs behavior, verify in an actual + `emacs -nw` session as well as batch mode; batch load alone will not catch + interactive tty regressions. ## Git Workflow Expectations - Use a git-based workflow for repository changes. - Assume changes will be committed incrementally as the work progresses. -- Keep changes small and focused so they can be reviewed and committed independently. +- Keep changes small and focused so they can be reviewed and committed + independently. - Treat verification as the gate for each commit-sized unit of work. -- After verification passes for a unit of work, summarize the result and prompt the user before creating the commit. +- After verification passes for a unit of work, summarize the result and prompt + the user before creating the commit. - Keep commit messages concise and specific to the verified change. - Do not bundle unrelated changes into the same commit. ## Documentation Expectations -- Document non-obvious conventions close to the relevant configuration in `config.org`. -- Update `README.md` whenever configuration or workflow changes alter package usage, startup behavior, keybindings, directory layout, capture flow, or other documented behavior. -- `README.md` must describe the current configuration truthfully. Do not leave stale documentation behind and do not document planned behavior as current behavior. -- Keep README and AGENTS consistent. State rules explicitly and avoid hidden or heuristic behavior in documentation. +- Document non-obvious conventions close to the relevant Lisp module. +- Update `README.md` whenever configuration or workflow changes alter package + usage, startup behavior, keybindings, directory layout, capture flow, or + other documented behavior. +- `README.md` must describe the current configuration truthfully. Do not leave + stale documentation behind and do not document planned behavior as current + behavior. +- Keep `README.md` and `AGENTS.md` in sync with the modular architecture. - Use `~/org` consistently when describing the external notes directory. -- Before claiming a change is complete or asking to commit it, review whether `README.md` needs an update; if it does not, say so explicitly in the summary. -- Update AGENTS.md when the repo workflow, Org layout, or verification expectations materially change. -- Keep AGENTS.md concise and update it only when the repository structure or working rules actually change. +- Before claiming a change is complete or asking to commit it, review whether + `README.md` needs an update; if it does not, say so explicitly in the + summary. +- Keep `AGENTS.md` concise and update it only when the repository structure or + working rules actually change. @@ -1,243 +1,207 @@ # Emacs Configuration -This repository contains a literate Emacs configuration built around Org mode, Denote, a PARA-style note layout, and a small completion stack. The hand-edited configuration source is `config.org`; `init.el` and `early-init.el` are generated from it. +This repository contains a modular Emacs configuration built around Org mode, +Denote, a PARA-style note layout, a people CRM, and a small completion stack. +`init.el` is the hand-edited entry point, `early-init.el` handles true early +startup concerns, and the runtime implementation is hand-edited across the +domain modules under `lisp/`. ## System Model -This repository configures Emacs. It does not define, create, or validate the `~/org` note system. +This repository configures Emacs. It does not define, create, or validate the +`~/org` note system. -- `config.org` is the source of truth for Emacs configuration only. +- `init.el` is the source of truth for runtime composition and feature + selection. +- `early-init.el` is the source of truth for true early startup settings. +- `lisp/ss-*.el` is the source of truth for the runtime implementation by + domain. - `~/org` is external to this repository and must already exist. -- The configuration may open files in `~/org`, but it must not create directories, create files, or validate note structure. -- `~/org/journal.org` is the operational journal. It must already exist, and the configuration may open it but must not create or manage it. -- `~/org/moc.org` is a normal note. It must already exist, and the configuration may open it but must not create or manage it. -- `~/org/areas/people/people.org` is the people CRM file. It must already exist, and the configuration may open it but must not create or manage it. -- PARA is the organising model for durable notes. Folder placement carries meaning, and workflows must respect that placement. -- The system optimises for speed of capture and minimal friction. -- The system must prefer explicit rules over implicit behaviour and must avoid over-structuring, manual overhead, and inconsistency. - -## External Invariants - -These are external note-system invariants. The configuration assumes them, but it does not manage them. - -- `~/org` must exist. -- `~/org/journal.org` must exist. -- `~/org/moc.org` must exist. -- `~/org/areas/people/people.org` must exist. - -## Emacs Setup - -### Source of truth and generated files - -`config.org` is the only file intended for manual Emacs configuration edits. The generated startup files are: - -- `early-init.el` for settings that must exist before the first GUI frame. -- `init.el` for the main runtime configuration. - -Both generated files are tangled from `config.org` and should be treated as build artifacts. - -### Package bootstrap - -The config bootstraps packages with built-in `package.el` and uses `use-package` for declaration and load order. Package archives are configured with GNU, NonGNU ELPA, and MELPA, with GNU given highest priority. - -### Core packages and built-in modules - -The current setup uses these packages and built-in modules: - -- `org` and `org-capture` for agenda, journal capture, and the literate configuration itself. -- `denote` for durable notes, naming, keywords, and linking. -- `git-auto-commit-mode` for optional automatic commits inside `~/org` when enabled by directory-local settings. -- `vertico` for minibuffer completion UI. -- `orderless` for flexible completion matching. -- `marginalia` for minibuffer annotations. -- `gptel` with the GitHub Copilot backend, currently being trialled as an experimental tool rather than a defined workflow. -- `dired` with a macOS-safe `ls` configuration. -- `time` for the modeline clock. -- `modus-themes`, using `modus-vivendi` in the current config. +- The configuration may open files in `~/org`, but it must not create + directories, create files, or validate note structure. +- `~/org/journal.org` is the operational journal. It must already exist, and + the configuration may open it but must not create or manage it. +- `~/org/moc.org` is a normal note. It must already exist, and the + configuration may open it but must not create or manage it. +- `~/org/areas/people/people.org` is the people CRM file. It must already + exist, and the configuration may open it but must not create or manage it. +- PARA is the organising model for durable notes. Folder placement carries + meaning, and workflows must respect that placement. + +## Repository Layout + +The runtime architecture is: + +```text +early-init.el +init.el +lisp/ + ss-core.el + ss-ui.el + ss-org.el + ss-agenda.el + ss-capture.el + ss-denote.el + ss-crm.el + ss-gptel.el + ss-keys.el +``` -### Org mode and note layout +The module responsibilities are: + +- `ss-core.el` bootstraps packages, defines shared paths and helpers, and + applies shared editor defaults. +- `ss-ui.el` owns theme, fonts, frame behavior, modeline, and completion UI. +- `ss-org.el` owns base Org setup, startup MOC behavior, and shared note + helpers. +- `ss-agenda.el` owns agenda discovery and agenda commands. +- `ss-capture.el` owns journal capture structure and capture templates. +- `ss-denote.el` owns Denote setup and durable-note capture helpers. +- `ss-crm.el` owns all people CRM behavior. +- `ss-gptel.el` owns the experimental GitHub Copilot-backed `gptel` setup. +- `ss-keys.el` owns global keybindings only. + +`init.el` enables high-level features centrally through `ss-enabled-features`. +Feature toggling works by including or excluding a module there. + +## Package Model + +The config bootstraps packages with built-in `package.el` and uses +`use-package` for declaration and load order. Package archives are configured +with GNU, NonGNU ELPA, and MELPA, with GNU given highest priority. + +The current setup uses: + +- `org` and `org-capture` for agenda and journal capture +- `denote` for durable notes, naming, keywords, and linking +- `git-auto-commit-mode` for optional automatic commits inside `~/org` when + enabled by directory-local settings +- `vertico` for minibuffer completion UI +- `orderless` for flexible completion matching +- `marginalia` for minibuffer annotations +- `corfu` for in-buffer completion popups in text and Org buffers +- `gptel` with the GitHub Copilot backend as an experimental tool +- `dired` with a macOS-safe `ls` configuration +- `time` for the modeline clock +- `modus-themes`, using `modus-vivendi` + +## Org Layout The note system lives under `~/org/` and is organised like this: -- `journal.org` for the operational journal. -- `daily/` for older plain daily Org files that may still exist outside the active capture workflow. -- `projects/` for project notes. -- `areas/` for area notes. -- `areas/people/people.org` for the people CRM. -- `resources/` for reference material. -- `archives/` for archived notes. - -This is a PARA-style layout. Folder placement carries meaning. Denote keywords are used sparingly, with `project` kept as the only built-in structural keyword because project titles are often ambiguous outside their folder. - -### Agenda Rules +- `journal.org` for the operational journal +- `daily/` for older daily Org files that may still exist +- `projects/` for project notes +- `areas/` for area notes +- `areas/people/people.org` for the people CRM +- `resources/` for reference material +- `archives/` for archived notes -The agenda is rule-based. +Agenda discovery is rule-based: -- The agenda must include `~/org/journal.org`. -- The agenda must include recursive scans of `.org` files under `~/org/projects/`, `~/org/areas/`, and `~/org/resources/`. -- The agenda must exclude `~/org/archives/`. -- The agenda must use recursive discovery and explicit include and exclude rules rather than heuristic selection. - -### Completion setup - -The minibuffer stack is intentionally small: - -- `vertico` provides the completion UI. -- `orderless` handles matching. -- `marginalia` adds annotations. -- `corfu` handles in-buffer completion popups for text and Org buffers. +- include `~/org/journal.org` +- recursively scan `.org` files under `~/org/projects/`, `~/org/areas/`, and + `~/org/resources/` +- exclude `~/org/archives/` ## People CRM The people workflow is a CRM rooted at `~/org/areas/people/people.org`. -- Each top-level heading represents one person. -- Entries are strictly structured around heading text and flat properties. -- The system generates abbrevs from the people file. -- The system provides canonical-name completion through a CAPF. -- The system provides reporting views grouped by person properties. -- The system rebuilds on any people-file change, whether the change comes from manual editing or from a command. -- The system must remain up to date after changes. -- Failures must surface visibly. The system must not silently fall back to stale state. -- The CRM is individual-focused: `TEAM` captures the current working team and `MANAGER` captures the formal organisational manager. -- Team structure, org charts, and matrix relationships belong in separate notes, not in the CRM. -- Person cards use `ROLE`, `TEAM`, `MANAGER`, `ENGAGEMENT`, `SUPPLIER`, `LOCATION`, and `CURRENT_FOCUS` in that order. -- `ENGAGEMENT` and `SUPPLIER` both use fixed lookup lists in `ss/crm-add` so reports stay consistent. -- `CURRENT_FOCUS` must stay short and phrase-like so summaries and completion annotations remain readable. - -Name entry uses fixed abbrevs plus the people CRM: - -- `abbrev` provides deterministic one-shot shortcuts for fixed name expansions. -- people-specific abbrevs are generated from top-level cards in `~/org/areas/people/people.org`. -- a CAPF feeds Corfu canonical names from `~/org/areas/people/people.org`, while alias matching stays available for lookup and completion. -- Marginalia annotates person candidates with `role | team | engagement | current focus`. -- `M-x ss/crm-open` opens `~/org/areas/people/people.org` in overview mode through `ss/crm-overview`. -- `M-x ss/crm-find` opens a person card narrowed to that subtree. -- `M-x ss/crm-overview` opens the people file in overview mode, resetting the file by widening and restoring the overview when leaving card view. -- `M-x ss/crm-insert-name` inserts the canonical name at point. -- `M-x ss/crm-insert-summary` inserts a compact single-line summary at point. -- `M-x ss/crm-add` adds a new person card directly to `~/org/areas/people/people.org`. -- `M-x ss/crm-report-by-role`, `M-x ss/crm-report-by-team`, `M-x ss/crm-report-by-manager`, `M-x ss/crm-report-by-engagement`, `M-x ss/crm-report-by-supplier`, and `M-x ss/crm-report-by-location` render grouped Org reports in a read-only buffer. - -### Persistent abbrevs - -Persistent abbrevs live in `abbrev_defs` at the repository root. The config loads that file on startup, enables abbrev mode only in text-like buffers, and saves learned abbrevs back to the same file silently when buffers are saved. -People-specific abbrevs are not stored in a separate file. They are rebuilt from `~/org/areas/people/people.org` whenever the people file changes, and missing `ABBREV` properties fall back to a generated default trigger. - -### Babel tangle process - -The literate config uses Org Babel to generate the runtime files. Most Emacs Lisp blocks inherit `:tangle init.el` from the file header, while early-startup blocks explicitly tangle to `early-init.el`. - -To regenerate the generated files from the repo root: - -```sh -emacs --batch -Q --eval '(progn (require (quote ob-tangle)) (org-babel-tangle-file "config.org"))' -``` - -Or use the helper script: - -```sh -./build -``` - -To verify that the generated main config still loads: - -```sh -emacs --batch -Q --load ./init.el -``` - -To remove generated startup files, `custom.el`, and common working directories -from the repo root: - -```sh -./reset -``` +- each top-level heading represents one person +- entries are structured around heading text and flat properties +- the system rebuilds people abbrevs from the CRM file +- a CAPF provides canonical-name completion while alias matching remains + available for lookup +- Marginalia annotates people with `role | team | engagement | current focus` +- reports are available by role, team, manager, engagement, supplier, and + location +- `TEAM` captures the current working team and `MANAGER` captures the formal + organisational manager +- person cards use `ROLE`, `TEAM`, `MANAGER`, `ENGAGEMENT`, `SUPPLIER`, + `LOCATION`, and `CURRENT_FOCUS` in that order + +The CRM commands are: + +- `M-x ss-crm-open` +- `M-x ss-crm-overview` +- `M-x ss-crm-find` +- `M-x ss-crm-insert-name` +- `M-x ss-crm-insert-summary` +- `M-x ss-crm-add` +- `M-x ss-crm-report-by-role` +- `M-x ss-crm-report-by-team` +- `M-x ss-crm-report-by-manager` +- `M-x ss-crm-report-by-engagement` +- `M-x ss-crm-report-by-supplier` +- `M-x ss-crm-report-by-location` + +Persistent abbrevs live in `abbrev_defs` at the repository root. The config +loads that file on startup, enables abbrev mode only in text-like buffers, and +saves learned abbrevs back to the same file silently when buffers are saved. +People-specific abbrevs are rebuilt from `~/org/areas/people/people.org` +whenever that file changes. ## Workflow -### MOC - -`~/org/moc.org` is a normal note. It serves as a small curated navigation surface rather than an exhaustive index or system of record, and the configuration assumes the file already exists. - -The configuration may open the MOC automatically on startup, and `C-c n M` opens it manually. The configuration must not create or manage the file. - -Its Quick Access section provides actionable links for opening the agenda, the journal, capture, and a new note, while the rest of the file stays lightweight and curated around active projects, areas, and a few high-leverage resources. - -### Capture Model - -The capture model has two distinct modes. +`~/org/moc.org` is a normal note. It is treated as a curated navigation note, +not a generated system file. The config may open it on startup, and `C-c n M` +opens it manually. -### Journal +The capture model has two distinct paths: -The operational journal lives in `~/org/journal.org`. +- fast operational capture goes to `~/org/journal.org` +- durable notes use Denote in the PARA directories under `~/org/` -- Journal capture uses a Year -> Day outline inside `journal.org`. -- Each day keeps explicit `Tasks`, `Notes`, and `Meetings` headings beneath the day entry. -- Journal capture is the fast path for operational work. -- The configuration assumes `~/org/journal.org` already exists and does not create it. +Journal capture uses a Year -> Day outline in `journal.org` with explicit +`Tasks`, `Notes`, and `Meetings` headings beneath each day entry. -### Durable notes - -Durable notes use Denote and live in the PARA directories under `~/org/`. - -- Durable notes are created through capture plus Denote. -- Durable notes are structured at creation time. -- Folder placement carries meaning. -- The workflow must respect PARA placement rather than relying on later manual cleanup. - -### Agenda usage - -The agenda is opened through `ss/open-agenda`, bound to `C-c a`. That command explicitly loads `org-agenda`, and the config refreshes `org-agenda-files` immediately before each `org-agenda` invocation. -Agenda file selection follows the canonical rules in the `Agenda Rules` section above. - -### Capture flow - -`C-c c` opens capture. The configured templates cover: +The configured capture templates cover: - journal tasks - journal notes - journal meetings - Denote-backed captures for generic notes, projects, areas, and resources -Journal task capture writes under the current day's `Tasks` heading. Journal note capture writes under `Notes`. Journal meeting capture writes under `Meetings`, and the meeting template prefixes the heading with the capture time. - -Denote captures still prompt for title, keywords, and subdirectory placement where appropriate, but folder placement does most of the classification work. The project capture template prepopulates the `project` keyword. Area and resource captures do not inject structural keywords automatically, and there is no Denote-backed meeting capture template. - -The people CRM is intentionally outside `org-capture`: `M-x ss/crm-add` writes a compact card directly into `~/org/areas/people/people.org`, keeping the people file as a fast reference file instead of another capture sink. - -### Note creation and linking - -Denote handles long-lived notes. The main bindings are: - -- `C-c n n` to open or create a Denote note. -- `C-c n l` to insert a Denote link. -- `C-c n M` to open the central MOC note. -- `C-c n E` to show people grouped by engagement. -- `C-c n f` to find a person card. -- `C-c n i` to insert a canonical person name. -- `C-c n I` to insert a compact person summary. -- `C-c n L` to show people grouped by location. -- `C-c n d` to open `~/org/journal.org`. -- `C-c n o` to restore the people overview. -- `C-c n O` to show people grouped by role. -- `C-c n p` to open `~/org/areas/people/people.org`. -- `C-c n P` to add a new person card. -- `C-c n R` to show people grouped by manager. -- `C-c n S` to show people grouped by supplier. -- `C-c n T` to show people grouped by team. - -Keyword prompts and directory placement are part of the workflow, not an afterthought. The config is set up so structure is created first, then capture writes into it, with folder placement carrying most of the durable type information. - -The intended split is explicit: - -- `~/org/journal.org` is for fast operational capture. -- Denote notes in the PARA directories are for durable content. - -### Automatic note commits - -The configuration provides `git-auto-commit-mode` capability. Behaviour is defined in `~/org/.dir-locals.el`. - -When enabled in `~/org/.dir-locals.el`, saving a file in `~/org/` makes Emacs try to commit that change. The configuration must not enforce policy for add, push, debounce, or commit-message behaviour. +The people CRM remains outside `org-capture`: `M-x ss-crm-add` writes directly +to `~/org/areas/people/people.org`. + +## Keybindings + +The main bindings are: + +- `C-c a` for the agenda +- `C-c c` for capture +- `C-c n n` to open or create a Denote note +- `C-c n l` to insert a Denote link +- `C-c n M` to open the MOC +- `C-c n d` to open `~/org/journal.org` +- `C-c n p` to open the people CRM +- `C-c n P` to add a new person card +- `C-c n f` to find a person card +- `C-c n i` to insert a canonical person name +- `C-c n I` to insert a compact person summary +- `C-c n o` to restore the people overview +- `C-c n O` to show people grouped by role +- `C-c n T` to show people grouped by team +- `C-c n R` to show people grouped by manager +- `C-c n E` to show people grouped by engagement +- `C-c n S` to show people grouped by supplier +- `C-c n L` to show people grouped by location +- `C-c n g` to start `gptel` +- `C-c n s` to send in `gptel` +- `C-c n r` to rewrite with `gptel` +- `C-c n a` to add context in `gptel` + +## Automatic Note Commits + +The configuration provides `git-auto-commit-mode` capability. Behaviour is +defined in `~/org/.dir-locals.el`. + +When enabled in `~/org/.dir-locals.el`, saving a file in `~/org/` makes Emacs +try to commit that change. The Emacs config supplies the package and selects +the shell command separator based on the active shell, while the note tree +defines add, push, debounce, and commit-message behavior. Place this file at `~/org/.dir-locals.el`: @@ -254,19 +218,33 @@ Place this file at `~/org/.dir-locals.el`: (git-auto-commit-mode 1)))))) ``` -That applies to buffers visiting files under `~/org/` and its subdirectories. The repository-local settings control whether new files are added, whether pushes occur, how long commits are debounced, and what commit message is used, while the Emacs config supplies the package itself and picks the correct shell command separator for the active shell. +## Validation + +The primary verification command for the runtime path is: + +```sh +emacs --batch -Q --load ./init.el +``` + +`early-init.el` and `init.el` are hand-edited source files, not generated +artifacts. -### Terminal and GUI behavior +## Terminal and GUI Behavior GUI Emacs and terminal Emacs are handled slightly differently. - GUI frames get the preferred frame size, font setup, and UI trimming. -- In `emacs -nw`, the menu bar is disabled on `emacs-startup-hook` rather than earlier in startup, because changing that timing too early caused interactive terminal regressions in kitty. +- In `emacs -nw`, the menu bar is disabled on `emacs-startup-hook` rather than + earlier in startup, because changing that timing too early caused + interactive terminal regressions in kitty. -If you change terminal behavior, test it in a real `emacs -nw` session. Batch load checks are necessary, but they are not enough for tty input and UI behavior. +If you change terminal behavior, test it in a real `emacs -nw` session. Batch +load checks are necessary, but they are not enough for tty input and UI +behavior. ## Maintenance Rules -- Update `config.org` first, then regenerate `init.el` and `early-init.el`. -- Keep this README aligned with the current configuration. If package usage, startup behavior, keybindings, or workflow changes, update this file in the same change. +- Edit `init.el`, `early-init.el`, and `lisp/ss-*.el` directly. +- Keep this README aligned with the current configuration. +- Keep `README.md` and `AGENTS.md` in sync. - Do not document planned behavior as if it already exists. diff --git a/abbrev_defs b/abbrev_defs new file mode 100644 index 0000000..64bee34 --- /dev/null +++ b/abbrev_defs @@ -0,0 +1 @@ +;;-*-coding: utf-8;-*- @@ -1,6 +0,0 @@ -#!/bin/sh - -set -eu - -exec emacs --batch -Q \ - --eval '(progn (require (quote ob-tangle)) (org-babel-tangle-file "config.org"))' diff --git a/config.org b/config.org deleted file mode 100644 index b0081c1..0000000 --- a/config.org +++ /dev/null @@ -1,1211 +0,0 @@ --*- buffer-read-only: t -*- -#+title: Emacs Configuration -#+startup: overview -#+DATE: 2026-03-24T10:00:00+11:00 -#+DRAFT: false -#+PROPERTY: header-args:emacs-lisp :results silent :tangle init.el - -* Early startup - -These settings have to exist before the first GUI frame is created, so they -tangle into =early-init.el=. - -#+begin_src emacs-lisp :tangle early-init.el - ;;; early-init.el --- generated from config.org -*- lexical-binding: t; -*- - - ;;; Commentary: - - ;; This file is generated from config.org. Do not edit it directly. - - ;;; Code: - - (dolist (parameter '((width . 140) - (height . 42))) - (add-to-list 'default-frame-alist parameter) - (add-to-list 'initial-frame-alist parameter)) -#+end_src - -* Bootstrapping - -This is the start of the main runtime entry point, which tangles into =init.el=. - -#+begin_src emacs-lisp - ;;; init.el --- generated from config.org -*- lexical-binding: t; -*- - - ;;; Commentary: - - ;; This file is generated from config.org. Do not edit it directly. - - ;;; Code: - - (let ((minver "27.1")) - (when (version< emacs-version minver) - (error "Your Emacs is too old -- this config requires v%s or higher" minver))) - (when (version< emacs-version "28.1") - (message - (concat - "Your Emacs is old, and some functionality in this config will be " - "disabled. Please upgrade if possible."))) -#+end_src - -* Shared paths and system identity - -These definitions set up the shared paths used by the rest of the -configuration, including the Org directory under =~/org/=. - -#+begin_src emacs-lisp - (defconst *spell-check-support-enabled* nil) - (defconst *is-a-windows* (memq system-type '(windows-nt ms-dos cygwin))) - (defconst *is-a-linux* (eq system-type 'gnu/linux)) - (defconst *is-a-mac* (eq system-type 'darwin)) - - (provide 'init-paths) - - ;; Keep custom-set-variables out of the main config. - (setq custom-file (expand-file-name "custom.el" user-emacs-directory)) -#+end_src - -* Package setup - -This section bootstraps packages and defines the archives the rest of the -configuration relies on. - -#+begin_src emacs-lisp - (require 'package) - - (setq package-archives - (append '(("melpa" . "https://melpa.org/packages/")) - package-archives) - package-archive-priorities '(("gnu" . 10) - ("nongnu" . 8) - ("melpa" . 5)) - package-install-upgrade-built-in t - use-package-always-ensure nil) - - (package-initialize) - (require 'use-package) -#+end_src - -* Interface defaults - -This section sets the visual defaults: theme, fonts, and frame behavior. - -#+begin_src emacs-lisp - (when (display-graphic-p) - (set-frame-size (selected-frame) 140 42) - (menu-bar-mode -1) - (tool-bar-mode -1) - (scroll-bar-mode -1) - (tooltip-mode -1) - (set-face-attribute - 'default nil - :family "JetBrains Mono" :height 140 :weight 'medium) - (set-face-attribute - 'fixed-pitch nil - :family "JetBrains Mono" :weight 'medium) - (set-face-attribute - 'fixed-pitch-serif nil - :family "JetBrains Mono" :weight 'medium)) - - (unless (display-graphic-p) - (add-hook 'emacs-startup-hook (lambda () (menu-bar-mode -1)))) - - (setq inhibit-startup-message t - inhibit-startup-screen t - ring-bell-function 'ignore) - - (use-package modus-themes - :ensure nil - :no-require t - :config - (load-theme 'modus-vivendi t)) - - (line-number-mode 1) - (column-number-mode 1) - (show-paren-mode 1) - - ;; Disable all fringe indicators - (setq-default indicate-empty-lines nil) - (setq-default indicate-buffer-boundaries nil) - (setq-default fringe-indicator-alist nil) - -#+end_src - -* Modeline - -#+begin_src emacs-lisp - (use-package time - :ensure nil - :config - ;; Enable 24-hour time display without load average. - (setq display-time-24hr-format t - display-time-day-and-date t - display-time-default-load-average nil - calendar-latitude -37.7667 - calendar-longitude 145.0 - calendar-location-name "Melbourne, VIC") - (display-time-mode 1)) - - ;; Keep the theme's faces, but make the right edge alignment dynamic. - (setq-default mode-line-format - (list - ;; Left padding - " " - "%e" - mode-line-front-space - mode-line-mule-info - mode-line-client - mode-line-modified - mode-line-remote - mode-line-frame-identification - mode-line-buffer-identification - " " - mode-line-position - '(vc-mode vc-mode) - " " - mode-line-modes - ;; Right-align from here - '(:eval (propertize - " " - 'display - `((space :align-to - (- right - ,(+ 2 (string-width (format-mode-line mode-line-misc-info)))))))) - mode-line-misc-info - ;; Right padding - " " - mode-line-end-spaces)) - -#+end_src - -* Editing defaults - -This section covers global editing behavior and a few startup-time tuning -choices. - -#+begin_src emacs-lisp - (require 'abbrev) - - (set-language-environment "UTF-8") - (set-default-coding-systems 'utf-8) - (prefer-coding-system 'utf-8) - - (setq abbrev-file-name (expand-file-name "abbrev_defs" user-emacs-directory) - save-abbrevs 'silently) - (when (file-exists-p abbrev-file-name) - (quietly-read-abbrev-file abbrev-file-name)) - - (defun ss/enable-prose-abbrev-mode () - "Enable abbrev mode in prose buffers. -We keep this mode-local so code buffers stay on their own completion rules." - (abbrev-mode 1)) - - (dolist (hook '(text-mode-hook org-mode-hook)) - (add-hook hook #'ss/enable-prose-abbrev-mode)) - - (setq auto-save-default nil - backup-inhibited t - echo-keystrokes 0.1 - compilation-ask-about-save nil - mouse-wheel-scroll-amount '(1 ((shift) . 1)) - mouse-wheel-progressive-speed nil - mouse-wheel-follow-mouse t - scroll-step 1 - scroll-conservatively 101 - enable-recursive-minibuffers t - gc-cons-threshold (* 128 1024 1024) - read-process-output-max (* 4 1024 1024) - process-adaptive-read-buffering nil) - - (fset 'yes-or-no-p 'y-or-n-p) - (global-auto-revert-mode 1) - (delete-selection-mode 1) - - (setq-default indent-tabs-mode nil - fill-column 80 - tab-width 2 - indicate-empty-lines t - sentence-end-double-space nil) - -#+end_src - -* Dired - -On macOS, the system =ls= does not support GNU's =--dired= flag. Keeping -Dired on its built-in path avoids noisy directory listing errors when opening -folders from note helpers. - -#+begin_src emacs-lisp - (use-package dired - :ensure nil - :custom - (dired-use-ls-dired nil)) -#+end_src - -* Minibuffer completion - -Completion stays close to standard Emacs behaviour while improving the -minibuffer prompts used throughout the notes workflow. Vertico provides the -UI, Orderless handles flexible matching, and Marginalia adds lightweight -annotations. - -#+begin_src emacs-lisp - (use-package vertico - :ensure t - :pin melpa - :init - (vertico-mode 1)) - - (use-package orderless - :ensure t - :pin melpa - :custom - (completion-styles '(orderless basic)) - (completion-category-defaults nil) - (completion-category-overrides '((file (styles basic partial-completion))))) - - (use-package marginalia - :ensure t - :pin melpa - :after vertico - :init - (marginalia-mode 1)) - - (use-package corfu - :ensure t - :pin gnu - :init - ;; Enable Corfu globally so text and Org buffers get in-buffer completion - ;; popups when a CAPF provides candidates. - (global-corfu-mode 1)) -#+end_src - -* People CRM - -The people workflow is a CRM backed by the canonical -=~/org/areas/people/people.org= file. Each top-level heading is a compact card -with properties for lookup, completion, reports, and abbrevs. Abbrev remains -the fast path for names you type all the time, while CAPF plus Corfu remains -the discovery path. The machine-facing layer only reads heading text and -properties; the =Context= and =TODOs= sections stay human-facing notes. The -CRM stays individual-focused: =TEAM= captures the current working team and -=MANAGER= captures the formal organisational manager, but organisational -structure lives in separate notes rather than in the CRM itself. - -The CRM is designed around =ss/crm-overview=: opening the file starts in -overview mode, direct visits reset back to overview mode, and exiting card view -means widening the buffer and restoring that overview. Keep =CURRENT_FOCUS= -short and phrase-like so summaries and completion annotations stay readable. -Person cards use a flat property model in this order: =ROLE=, =TEAM=, -=MANAGER=, =ENGAGEMENT=, =SUPPLIER=, =LOCATION=, and =CURRENT_FOCUS=. -=ENGAGEMENT= and =SUPPLIER= both use fixed lookup lists in =ss/crm-add= so -reports stay consistent. =ABBREV= and =ALIASES= remain optional helpers for -lookup and insertion. - -#+begin_src emacs-lisp - (require 'seq) - (require 'subr-x) - (require 'marginalia nil t) - - (defconst ss/crm-file - (expand-file-name "areas/people/people.org" "~/org/") - "Single source of truth for the people CRM.") - - (defconst ss/crm-engagement-options - '("Perm" "SOW" "SOW Fixed Outcome" "NCS India") - "Canonical engagement values for people cards.") - - (defconst ss/crm-supplier-options - '("Accenture Song" - "INFOSYS TECHNOLOGIES LIMITED" - "MAKK Integrations Pty Ltd" - "NCSI Technologies India Private Limited" - "TECH MAHINDRA LTD") - "Canonical supplier values for people cards.") - - (defvar ss/crm--cache nil - "Cached CRM entries loaded from `ss/crm-file'.") - - (defvar ss/crm--cache-mtime nil - "Modification time of the cached CRM entries.") - - (defun ss/crm--entry-name (entry) - "Return the canonical name in ENTRY." - (plist-get entry :name)) - - (defun ss/crm--entry-abbrev (entry) - "Return the abbrev trigger in ENTRY." - (plist-get entry :abbrev)) - - (defun ss/crm--entry-aliases (entry) - "Return alias variants in ENTRY." - (plist-get entry :aliases)) - - (defun ss/crm--entry-role (entry) - "Return the role in ENTRY." - (plist-get entry :role)) - - (defun ss/crm--entry-location (entry) - "Return the location in ENTRY." - (plist-get entry :location)) - - (defun ss/crm--entry-engagement (entry) - "Return the engagement in ENTRY." - (plist-get entry :engagement)) - - (defun ss/crm--entry-supplier (entry) - "Return the supplier in ENTRY." - (plist-get entry :supplier)) - - (defun ss/crm--entry-manager (entry) - "Return the manager in ENTRY." - (plist-get entry :manager)) - - (defun ss/crm--entry-current-focus (entry) - "Return the current focus in ENTRY." - (plist-get entry :current-focus)) - - (defun ss/crm--entry-team (entry) - "Return the team in ENTRY." - (plist-get entry :team)) - - (defun ss/crm-default-abbrev (name) - "Suggest a short abbrev trigger for NAME." - (let* ((parts (split-string (string-trim name) "[[:space:]]+" t)) - (first (downcase (substring (car parts) 0 (min 2 (length (car parts)))))) - (last (downcase (substring (car (last parts)) 0 1)))) - (if (> (length parts) 1) - (concat ";" first last) - (concat ";" first)))) - - (defun ss/crm--split-values (value) - "Split VALUE on commas and trim each item." - (when (and value (not (string-empty-p value))) - (seq-filter - (lambda (string) (not (string-empty-p string))) - (mapcar #'string-trim (split-string value "," t))))) - - (defun ss/crm--summary (entry) - "Return the compact one-line summary for ENTRY." - (string-join - (seq-filter - (lambda (string) (and string (not (string-empty-p string)))) - (list (ss/crm--entry-role entry) - (ss/crm--entry-team entry) - (ss/crm--entry-engagement entry) - (ss/crm--entry-current-focus entry))) - " | ")) - - (defun ss/crm--display (entry) - "Return the compact display string for ENTRY." - (let ((summary (ss/crm--summary entry))) - (if (string-empty-p summary) - (ss/crm--entry-name entry) - (format "%s %s" (ss/crm--entry-name entry) summary)))) - - (defun ss/crm--property-line (key value) - "Return an Org property line for KEY and VALUE." - (if (and value (not (string-empty-p value))) - (format ":%s: %s\n" key value) - "")) - - (defun ss/crm--require-file () - "Return `ss/crm-file', signaling when it is unavailable." - (unless (file-exists-p ss/crm-file) - (user-error "People file does not exist: %s" ss/crm-file)) - ss/crm-file) - - (defun ss/crm-entries () - "Return top-level people cards from `ss/crm-file'." - (let* ((file (ss/crm--require-file)) - (attributes (file-attributes file)) - (mtime (file-attribute-modification-time attributes))) - (unless (and ss/crm--cache - (equal mtime ss/crm--cache-mtime)) - (let ((entries - (with-temp-buffer - (insert-file-contents file) - ;; Parse cards without running user hooks; otherwise the - ;; CRM's own Org hooks recurse back into this parser. - (delay-mode-hooks - (org-mode)) - (goto-char (point-min)) - (let (cards) - (org-element-map (org-element-parse-buffer) 'headline - (lambda (headline) - (when (= 1 (org-element-property :level headline)) - (goto-char (org-element-property :begin headline)) - (push (list :name (org-element-property :raw-value headline) - :abbrev (org-entry-get nil "ABBREV") - :aliases (ss/crm--split-values - (org-entry-get nil "ALIASES")) - :role (org-entry-get nil "ROLE") - :team (org-entry-get nil "TEAM") - :manager (org-entry-get nil "MANAGER") - :engagement (org-entry-get nil "ENGAGEMENT") - :supplier (org-entry-get nil "SUPPLIER") - :location (org-entry-get nil "LOCATION") - :current-focus (org-entry-get nil "CURRENT_FOCUS") - ) - cards)))) - (sort cards - (lambda (left right) - (string< (ss/crm--entry-name left) - (ss/crm--entry-name right)))))))) - (setq ss/crm--cache entries - ss/crm--cache-mtime mtime))) - ss/crm--cache)) - - (defun ss/crm-reload () - "Reload the people cache and refresh prose buffers." - (interactive) - (setq ss/crm--cache nil - ss/crm--cache-mtime nil) - (ss/crm-refresh-buffers) - (message "Reloaded people CRM")) - - (defun ss/crm--entry-by-name (name) - "Return the people entry matching canonical NAME." - (seq-find - (lambda (entry) - (string= name (ss/crm--entry-name entry))) - (ss/crm-entries))) - - (defun ss/crm--search-keys (entry) - "Return canonical and alias search keys for ENTRY." - (cons (ss/crm--entry-name entry) - (ss/crm--entry-aliases entry))) - - (defun ss/crm--match-p (query entry) - "Return non-nil when QUERY matches ENTRY name or aliases." - (let* ((parts (split-string (downcase (string-trim query)) "[[:space:]]+" t)) - (keys (mapcar #'downcase (ss/crm--search-keys entry)))) - (seq-every-p - (lambda (part) - (seq-some (lambda (key) - (string-match-p (regexp-quote part) key)) - keys)) - parts))) - - (defun ss/crm--matching-entries (query) - "Return entries whose canonical name or aliases match QUERY." - (let ((entries (ss/crm-entries))) - (if (string-empty-p (string-trim query)) - entries - (seq-filter (lambda (entry) - (ss/crm--match-p query entry)) - entries)))) - - (defun ss/crm--completion-table (string pred action) - "Complete canonical people names while matching aliases via STRING." - (if (eq action 'metadata) - '(metadata (category . ss-person)) - (complete-with-action - action - (mapcar #'ss/crm--entry-name (ss/crm--matching-entries string)) - string - pred))) - - (defun ss/crm-marginalia-annotator (candidate) - "Return a Marginalia annotation for person CANDIDATE." - (when-let ((entry (ss/crm--entry-by-name candidate))) - (concat " " (ss/crm--summary entry)))) - - (defun ss/crm-select-entry (&optional prompt) - "Select a person entry using PROMPT." - (let ((completion-extra-properties '(:annotation-function ss/crm-marginalia-annotator))) - (ss/crm--entry-by-name - (completing-read (or prompt "Person: ") - #'ss/crm--completion-table - nil - t)))) - - (defun ss/crm-overview () - "Open `ss/crm-file' in overview mode, widening first when needed." - (interactive) - (unless (and buffer-file-name - (string= (file-truename buffer-file-name) - (file-truename ss/crm-file))) - (find-file (ss/crm--require-file))) - (widen) - (goto-char (point-min)) - (org-overview) - (org-cycle-hide-drawers 'all)) - - (defun ss/crm-open () - "Open the people CRM by delegating to `ss/crm-overview'." - (interactive) - (ss/crm-overview)) - - (defun ss/crm--track-buffer () - "Refresh CRM caches when `ss/crm-file' is saved." - (when (and buffer-file-name - (string= (file-truename buffer-file-name) - (file-truename ss/crm-file))) - (add-hook 'after-save-hook #'ss/crm-reload nil t))) - - (defun ss/crm--source-buffer-p () - "Return non-nil when the current buffer visits `ss/crm-file'." - (and buffer-file-name - (string= (file-truename buffer-file-name) - (file-truename ss/crm-file)))) - - (defun ss/crm--open-entry (entry) - "Open the people CRM file, then narrow to ENTRY for card view." - (find-file (ss/crm--require-file)) - (widen) - (let ((position (org-find-exact-headline-in-buffer - (ss/crm--entry-name entry)))) - (unless position - (user-error "No people card for %s" (ss/crm--entry-name entry))) - (goto-char position)) - (org-narrow-to-subtree) - (org-fold-show-subtree) - (org-show-entry) - (goto-char (point-min))) - - (defun ss/crm-find () - "Find a person and open that card." - (interactive) - (ss/crm--open-entry - (or (ss/crm-select-entry "Find person: ") - (user-error "No person selected")))) - - (defun ss/crm-insert-name () - "Insert a canonical person name at point." - (interactive) - (let ((entry (or (ss/crm-select-entry "Insert person name: ") - (user-error "No person selected")))) - (insert (ss/crm--entry-name entry)))) - - (defun ss/crm-insert-summary () - "Insert a compact person summary at point." - (interactive) - (let ((entry (or (ss/crm-select-entry "Insert person summary: ") - (user-error "No person selected")))) - (insert (ss/crm--display entry)))) - - (defun ss/crm--report-buffer (title group-fn) - "Render a grouped CRM report titled TITLE using GROUP-FN." - (let ((groups - (sort (seq-group-by - (lambda (entry) - (let ((value (funcall group-fn entry))) - (if (string-empty-p (or value "")) - "(none)" - value))) - (ss/crm-entries)) - (lambda (left right) - (string< (car left) (car right)))))) - (with-current-buffer (get-buffer-create "*People Report*") - (let ((inhibit-read-only t)) - (erase-buffer) - (org-mode) - (insert "#+title: " title "\n\n") - (dolist (group groups) - (insert "* " (car group) "\n") - (dolist (entry (sort (copy-sequence (cdr group)) - (lambda (left right) - (string< (ss/crm--entry-name left) - (ss/crm--entry-name right))))) - (insert "- " (ss/crm--display entry) "\n"))) - (goto-char (point-min)) - (read-only-mode 1) - (view-mode 1)) - (pop-to-buffer (current-buffer))))) - - (defun ss/crm-report-by-team () - "Show people grouped by team." - (interactive) - (ss/crm--report-buffer - "People by team" - #'ss/crm--entry-team)) - - (defun ss/crm-report-by-manager () - "Show people grouped by manager." - (interactive) - (ss/crm--report-buffer - "People by manager" - #'ss/crm--entry-manager)) - - (defun ss/crm-report-by-engagement () - "Show people grouped by engagement." - (interactive) - (ss/crm--report-buffer - "People by engagement" - #'ss/crm--entry-engagement)) - - (defun ss/crm-report-by-supplier () - "Show non-empty suppliers grouped by supplier." - (interactive) - (let ((groups - (sort (seq-group-by - #'ss/crm--entry-supplier - (seq-filter - (lambda (entry) - (not (string-empty-p (or (ss/crm--entry-supplier entry) "")))) - (ss/crm-entries))) - (lambda (left right) - (string< (car left) (car right)))))) - (with-current-buffer (get-buffer-create "*People Report*") - (let ((inhibit-read-only t)) - (erase-buffer) - (org-mode) - (insert "#+title: People by supplier\n\n") - (dolist (group groups) - (insert "* " (car group) "\n") - (dolist (entry (sort (copy-sequence (cdr group)) - (lambda (left right) - (string< (ss/crm--entry-name left) - (ss/crm--entry-name right))))) - (insert "- " (ss/crm--display entry) "\n"))) - (goto-char (point-min)) - (read-only-mode 1) - (view-mode 1)) - (pop-to-buffer (current-buffer))))) - - (defun ss/crm-report-by-role () - "Show people grouped by role." - (interactive) - (ss/crm--report-buffer - "People by role" - #'ss/crm--entry-role)) - - (defun ss/crm-report-by-location () - "Show people grouped by location." - (interactive) - (ss/crm--report-buffer - "People by location" - #'ss/crm--entry-location)) - - (defun ss/crm-read-string (prompt &optional default) - "Read PROMPT and trim the result." - (string-trim (read-string prompt nil nil default))) - - (defun ss/crm-read-required-string (prompt &optional default) - "Read PROMPT and require a non-empty result." - (let ((value (ss/crm-read-string prompt default))) - (if (string-empty-p value) - (user-error "%s is required" (string-remove-suffix ": " prompt)) - value))) - - (defun ss/crm-read-optional-string (prompt) - "Read PROMPT and return nil when the answer is empty." - (let ((value (ss/crm-read-string prompt))) - (unless (string-empty-p value) - value))) - - (defun ss/crm-add () - "Add a new compact person card to `ss/crm-file'." - (interactive) - (let* ((name (ss/crm-read-required-string "Full name: ")) - (abbrev (ss/crm-read-string "Abbrev: " (ss/crm-default-abbrev name))) - (aliases (ss/crm-read-string "Aliases (comma-separated, optional): ")) - (role (ss/crm-read-required-string "Role: ")) - (team (ss/crm-read-required-string "Team: ")) - (manager (ss/crm-read-required-string "Manager: ")) - (engagement (completing-read "Engagement: " - ss/crm-engagement-options nil t)) - (supplier (completing-read "Supplier: " - ss/crm-supplier-options nil t)) - (location (ss/crm-read-required-string "Location: ")) - (current-focus (ss/crm-read-required-string "Current focus: ")) - ) - (when (ss/crm--entry-by-name name) - (user-error "A person card for %s already exists" name)) - (when (string-empty-p abbrev) - (setq abbrev (ss/crm-default-abbrev name))) - (find-file (ss/crm--require-file)) - (widen) - (goto-char (point-max)) - (unless (bolp) - (insert "\n")) - (unless (looking-back "\n\n" nil) - (insert "\n")) - (insert "* " name "\n" - ":PROPERTIES:\n" - (ss/crm--property-line "ABBREV" abbrev) - (ss/crm--property-line "ALIASES" aliases) - (ss/crm--property-line "ROLE" role) - (ss/crm--property-line "TEAM" team) - (ss/crm--property-line "MANAGER" manager) - (ss/crm--property-line "ENGAGEMENT" engagement) - (ss/crm--property-line "SUPPLIER" supplier) - (ss/crm--property-line "LOCATION" location) - (ss/crm--property-line "CURRENT_FOCUS" current-focus) - ":END:\n\n" - "** Context\n\n" - "** TODOs\n") - (save-buffer) - (ss/crm-reload) - (ss/crm--open-entry (ss/crm--entry-by-name name)))) - - (defun ss/crm--clear-installed-abbrevs () - "Remove people-specific abbrevs from the current local table." - (mapatoms - (lambda (symbol) - (when (abbrev-get symbol :ss/crm) - (define-abbrev local-abbrev-table (symbol-name symbol) nil))) - local-abbrev-table)) - - (defun ss/crm-install-abbrevs () - "Install people abbrevs into the current buffer." - (unless (ss/crm--source-buffer-p) - (setq-local local-abbrev-table (copy-abbrev-table local-abbrev-table)) - (ss/crm--clear-installed-abbrevs) - (dolist (entry (ss/crm-entries)) - (let* ((name (ss/crm--entry-name entry)) - (abbrev (ss/crm--entry-abbrev entry)) - (abbrev-name - (if (or (null abbrev) (string-empty-p abbrev)) - (ss/crm-default-abbrev name) - abbrev))) - (define-abbrev local-abbrev-table abbrev-name name) - (when-let ((abbrev-symbol (abbrev-symbol abbrev-name local-abbrev-table))) - (abbrev-put abbrev-symbol :ss/crm t)))))) - - (defun ss/crm-refresh-buffers () - "Refresh people abbrevs in every prose buffer." - (dolist (buffer (buffer-list)) - (with-current-buffer buffer - (when (and (bound-and-true-p abbrev-mode) - (derived-mode-p 'text-mode 'org-mode)) - (ss/crm-install-abbrevs))))) - - (defun ss/crm-capf () - "Return canonical people completions at a word boundary." - (let ((end (point))) - (save-excursion - (skip-syntax-backward "w_") - (let ((beg (point))) - (when (< beg end) - ;; Verify this in real Org/text writing buffers, not just by inspection. - (let ((annotation - (lambda (candidate) - (when-let ((entry (ss/crm--entry-by-name candidate))) - (concat " " (ss/crm--summary entry))))) - (docsig - (lambda (candidate) - (when-let ((entry (ss/crm--entry-by-name candidate))) - (ss/crm--summary entry))))) - (list beg end #'ss/crm--completion-table - :exclusive 'no - :annotation-function annotation - :company-docsig docsig))))))) - - (defun ss/enable-people-capf () - "Add `ss/crm-capf' once in prose buffers." - (unless (or (ss/crm--source-buffer-p) - (memq #'ss/crm-capf completion-at-point-functions)) - (add-hook 'completion-at-point-functions #'ss/crm-capf nil t))) - - (defun ss/crm--maybe-overview-buffer () - "Reset the people CRM buffer to overview when visiting it directly." - (when (and buffer-file-name - (string= (file-truename buffer-file-name) - (file-truename ss/crm-file))) - (widen) - (goto-char (point-min)) - (org-overview) - (org-cycle-hide-drawers 'all))) - - (dolist (hook '(text-mode-hook org-mode-hook)) - (add-hook hook #'ss/enable-people-capf) - (add-hook hook #'ss/crm-install-abbrevs)) - (add-hook 'find-file-hook #'ss/crm--track-buffer) - (add-hook 'find-file-hook #'ss/crm--maybe-overview-buffer) -#+end_src - -* Notes workflow - -The note-taking system remains deliberately small. Fast operational capture -goes into =~/org/journal.org=, while longer-lived notes use Denote inside the -same root directory and rely on links for relationships. - -** Org foundations - -The Org setup establishes the shared directories used by the workflow and -provides helpers that open existing notes at point of use. Agenda views stay -focused on the journal plus PARA notes, so quick operational tasks and durable -project, area, and resource files can surface TODOs without pulling in archived -notes. A curated =moc.org= in the Org root acts as the startup landing page and -quick navigation surface. The config assumes that file already exists and opens -it directly during startup rather than creating it on demand. - -#+begin_src emacs-lisp - (use-package org - :ensure nil - :functions (denote-keywords-prompt) - :defines (denote-directory denote-use-directory denote-use-keywords) - :preface - (defconst ss/org-directory (expand-file-name "~/org/") - "Root directory for Org files.") - - (defconst ss/journal-file (expand-file-name "journal.org" ss/org-directory) - "Single-file work journal for operational capture.") - - (defconst ss/org-projects-directory (expand-file-name "projects/" ss/org-directory) - "Directory for project notes.") - - (defconst ss/org-areas-directory (expand-file-name "areas/" ss/org-directory) - "Directory for area notes.") - - (defconst ss/org-resources-directory (expand-file-name "resources/" ss/org-directory) - "Directory for resource notes.") - - (defconst ss/org-archives-directory (expand-file-name "archives/" ss/org-directory) - "Directory for archived notes.") - - (defconst ss/moc-file (expand-file-name "moc.org" ss/org-directory) - "Central MOC note.") - - (defconst ss/journal-section-headings - '("Tasks" "Notes" "Meetings") - "Per-day section headings maintained under each journal datetree entry.") - - (defconst ss/org-agenda-directories - (list ss/org-projects-directory - ss/org-areas-directory - ss/org-resources-directory) - "Directories whose Org files feed the agenda.") - - (defun ss/require-existing-directory (directory) - "Return DIRECTORY, signaling when it does not exist." - (unless (file-directory-p directory) - (user-error "Directory does not exist: %s" directory)) - directory) - - (defun ss/require-existing-file (file) - "Return FILE, signaling when it does not exist." - (unless (file-exists-p file) - (user-error "File does not exist: %s" file)) - file) - - (defun ss/denote-capture-in-directory (directory &optional keywords &rest prompts) - "Start a Denote Org capture in DIRECTORY with KEYWORDS and PROMPTS. - If PROMPTS is empty, rely on `denote-prompts'." - (let* ((prompt-for-keywords (memq :keywords prompts)) - (directory (ss/require-existing-directory directory)) - (denote-directory directory) - (denote-use-directory (unless (memq :subdirectory prompts) directory)) - (denote-use-keywords - (if prompt-for-keywords - (delete-dups (append keywords (denote-keywords-prompt))) - keywords))) - (if prompts - (denote-org-capture-with-prompts - (memq :title prompts) - nil - (memq :subdirectory prompts) - (memq :date prompts) - (memq :template prompts)) - (denote-org-capture)))) - - (defun ss/journal-capture-time () - "Return the effective timestamp for the current journal capture." - (or org-overriding-default-time - (org-capture-get :default-time) - (current-time))) - - (defun ss/journal-calendar-date (&optional time) - "Return TIME as a Gregorian date list for datetree helpers." - (calendar-gregorian-from-absolute - (time-to-days (or time (current-time))))) - - (defun ss/journal-year-heading (&optional time) - "Return the journal year heading text for TIME." - (format-time-string "%Y" (or time (current-time)))) - - (defun ss/journal-day-heading (&optional time) - "Return the journal day heading text for TIME." - (format-time-string "%Y-%m-%d %A" (or time (current-time)))) - - (defun ss/journal-find-or-create-heading (level heading) - "Move to HEADING at LEVEL, creating it when missing." - (goto-char (point-min)) - (if (re-search-forward - (format "^%s %s$" - (make-string level ?*) - (regexp-quote heading)) - nil t) - (goto-char (match-beginning 0)) - (goto-char (point-max)) - (unless (bolp) - (insert "\n")) - (insert (make-string level ?*) " " heading "\n") - (forward-line -1)) - (org-back-to-heading t)) - - (defun ss/journal-goto-date (&optional time create) - "Move to TIME's journal date heading. -When CREATE is non-nil, create the datetree entry when missing." - (goto-char (point-min)) - (if create - (let ((year-heading (ss/journal-year-heading time)) - (day-heading (ss/journal-day-heading time))) - (ss/journal-find-or-create-heading 1 year-heading) - (save-restriction - (org-narrow-to-subtree) - (ss/journal-find-or-create-heading 2 day-heading)) - t) - (when (re-search-forward - (format "^\\*\\* %s$" - (regexp-quote - (ss/journal-day-heading (or time (current-time))))) - nil t) - (goto-char (match-beginning 0)) - t))) - - (defun ss/journal-ensure-day-sections () - "Ensure the standard section headings exist under the current journal day." - (org-back-to-heading t) - (let ((section-level (1+ (org-outline-level)))) - (save-excursion - (save-restriction - (org-narrow-to-subtree) - (dolist (section ss/journal-section-headings) - (goto-char (point-min)) - (unless (org-find-exact-headline-in-buffer section) - (goto-char (point-max)) - (unless (bolp) - (insert "\n")) - (insert (make-string section-level ?*) " " section "\n"))))))) - - (defun ss/journal-goto-section (section &optional time) - "Move to SECTION beneath TIME's journal date, creating structure as needed." - (unless (member section ss/journal-section-headings) - (user-error "Unknown journal section: %s" section)) - (ss/journal-goto-date time 'create) - (ss/journal-ensure-day-sections) - (let ((section-level (1+ (org-outline-level))) - position) - (save-restriction - (org-narrow-to-subtree) - (goto-char (point-min)) - (when (re-search-forward - (format "^%s %s$" - (make-string section-level ?*) - (regexp-quote section)) - nil t) - (setq position (match-beginning 0)))) - (unless position - (user-error "Journal section not found: %s" section)) - (goto-char position) - (org-back-to-heading t))) - - (defun ss/journal-capture-target (section) - "Select SECTION under today's journal datetree entry for capture." - (set-buffer (find-file-noselect (ss/require-existing-file ss/journal-file))) - (widen) - (ss/journal-goto-section section (ss/journal-capture-time))) - - (defun ss/refresh-org-agenda-files (&rest _) - "Refresh `org-agenda-files' from the journal and PARA directories. - Ignore any arguments passed by advice wrappers." - (require 'org-agenda) - (setq org-agenda-files - (sort - (delete-dups - (append - (list (ss/require-existing-file ss/journal-file)) - (apply #'append - (mapcar (lambda (directory) - (directory-files-recursively - (ss/require-existing-directory directory) - "\\.org\\'")) - ss/org-agenda-directories)))) - #'string<))) - - (defun ss/open-journal () - "Open `ss/journal-file', moving to today's entry when it exists." - (interactive) - (find-file (ss/require-existing-file ss/journal-file)) - (widen) - (unless (ss/journal-goto-date) - (goto-char (point-max)))) - - (defun ss/open-moc () - "Open the central MOC note." - (interactive) - (find-file (ss/require-existing-file ss/moc-file))) - - (defun ss/open-agenda () - "Refresh agenda files and invoke `org-agenda'." - (interactive) - (call-interactively #'org-agenda)) - :init - (add-hook 'emacs-startup-hook - (lambda () - (find-file (ss/require-existing-file ss/moc-file)))) - :bind (("C-c a" . ss/open-agenda) - ("C-c c" . org-capture) - ("C-c n M" . ss/open-moc) - ("C-c n E" . ss/crm-report-by-engagement) - ("C-c n f" . ss/crm-find) - ("C-c n i" . ss/crm-insert-name) - ("C-c n I" . ss/crm-insert-summary) - ("C-c n L" . ss/crm-report-by-location) - ("C-c n d" . ss/open-journal) - ("C-c n o" . ss/crm-overview) - ("C-c n O" . ss/crm-report-by-role) - ("C-c n p" . ss/crm-open) - ("C-c n P" . ss/crm-add) - ("C-c n R" . ss/crm-report-by-manager) - ("C-c n S" . ss/crm-report-by-supplier) - ("C-c n T" . ss/crm-report-by-team)) - :config - (setq org-directory ss/org-directory - org-hide-emphasis-markers t - org-agenda-search-headline-for-time t) - (add-hook 'org-mode-hook - (lambda () - (setq-local org-hide-emphasis-markers t) - (font-lock-flush) - (font-lock-ensure))) - (advice-add 'org-agenda :before #'ss/refresh-org-agenda-files)) -#+end_src - -** Capture entry points - -Fast operational capture goes to =~/org/journal.org= using a datetree with -per-day =Tasks=, =Notes=, and =Meetings= headings. Denote capture uses Denote's -own Org integration so note identity, metadata, and directories stay under -Denote's control rather than custom code. The convenience templates keep the -familiar entry points, but only project capture injects a structural keyword by -default. The people CRM lives outside =org-capture=: adding a person uses the -dedicated =ss/crm-add= command so =~/org/areas/people/people.org= stays a -compact, structured card file rather than turning into another capture target. - -#+begin_src emacs-lisp - (use-package org-capture - :ensure nil - :after (org denote) - :config - (setq org-capture-templates - `(("j" "Journal") - ("jt" "Task" entry - (function (lambda () (ss/journal-capture-target "Tasks"))) - "* TODO %?") - ("jn" "Note" entry - (function (lambda () (ss/journal-capture-target "Notes"))) - "* %?") - ("jm" "Meeting" entry - (function (lambda () (ss/journal-capture-target "Meetings"))) - "* <%<%Y-%m-%d %H:%M>> %?") - ("n" "Denote") - ("nn" "Generic" plain - (file denote-last-path) - (function - (lambda () - (denote-org-capture-with-prompts :title :keywords :subdirectory))) - :no-save t - :immediate-finish nil - :kill-buffer t - :jump-to-captured t) - ("np" "Project" plain - (file denote-last-path) - (function - (lambda () - (ss/denote-capture-in-directory - ss/org-projects-directory '("project") :title :keywords :subdirectory))) - :no-save t - :immediate-finish nil - :kill-buffer t - :jump-to-captured t) - ("na" "Area" plain - (file denote-last-path) - (function - (lambda () - (ss/denote-capture-in-directory - ss/org-areas-directory nil :title :keywords :subdirectory))) - :no-save t - :immediate-finish nil - :kill-buffer t - :jump-to-captured t) - ("nr" "Resource" plain - (file denote-last-path) - (function - (lambda () - (ss/denote-capture-in-directory - ss/org-resources-directory nil :title :keywords :subdirectory))) - :no-save t - :immediate-finish nil - :kill-buffer t - :jump-to-captured t)))) -#+end_src - -** Denote - -Denote manages the durable notes. Folder placement carries most of the -structural meaning, while Denote handles naming, metadata, linking, and -retrieval. Built-in keywords stay intentionally sparse, with =project= -retained because project note titles are often ambiguous outside their folder. - -#+begin_src emacs-lisp - (use-package denote - :ensure t - :after org - :bind (("C-c n n" . denote-open-or-create) - ("C-c n l" . denote-link)) - :config - (setq denote-directory ss/org-directory - denote-known-keywords '("project") - denote-prompts '(title keywords) - denote-org-capture-specifiers "%?") - (denote-rename-buffer-mode 1)) -#+end_src - -** Automatic git commits - -The notes tree can enable =git-auto-commit-mode= through a =.dir-locals.el= -at =~/org/=. The Emacs config keeps the package available and sets the -machine-specific shell command chaining based on the active shell, while the -repo-local behavior lives with the notes tree. - -#+begin_src emacs-lisp - (use-package git-auto-commit-mode - :ensure t - :pin melpa - :commands (git-auto-commit-mode) - :init - (setq gac-shell-and - (if (string-match-p "fish\\'" shell-file-name) - " ; and " - " && "))) -#+end_src - -* Gptel workflow - -LLM chat remains a small workflow tool inside Emacs. GitHub Copilot -authentication is handled on demand by gptel, so there is no token plumbing in -this file. - -#+begin_src emacs-lisp - (use-package gptel - :ensure t - :init - (setq gptel-default-mode 'org-mode - gptel-model 'gpt-4o - gptel-backend (gptel-make-gh-copilot "Copilot")) - :bind (("C-c n g" . gptel) - ("C-c n s" . gptel-send) - ("C-c n r" . gptel-rewrite) - ("C-c n a" . gptel-add))) -#+end_src - -* Generated file footers - -The closing blocks just finish the generated startup files cleanly. - -#+begin_src emacs-lisp - - (when (file-exists-p custom-file) - (load custom-file nil 'nomessage)) - - ;;; init.el ends here -#+end_src - -#+begin_src emacs-lisp :tangle early-init.el - - ;;; early-init.el ends here -#+end_src diff --git a/docs/plans/2026-04-09-modular-emacs-architecture-design.md b/docs/plans/2026-04-09-modular-emacs-architecture-design.md new file mode 100644 index 0000000..1c2ce14 --- /dev/null +++ b/docs/plans/2026-04-09-modular-emacs-architecture-design.md @@ -0,0 +1,81 @@ +# Modular Emacs Architecture Design + +**Date:** 2026-04-09 + +## Goal + +Refactor the repository from a single literate `config.org` source into a +hand-edited modular Emacs configuration built around `init.el`, +`early-init.el`, and a small set of domain-based Lisp modules under `lisp/`, +while preserving existing behavior as closely as possible. + +## Scope + +This change is an architectural refactor, not a workflow redesign. Existing +startup behavior, packages, capture flows, agenda rules, CRM commands, +completion setup, and keybindings should remain materially the same unless a +small structural adjustment is required by the new module boundaries. + +## Architecture + +### Entry points + +- `early-init.el` remains a standalone file and contains only true early + startup concerns that must exist before the first GUI frame. +- `init.el` becomes the hand-edited runtime entry point. +- `init.el` adds `lisp/` to `load-path`, defines a central + `ss-enabled-features` list, requires `ss-core`, and conditionally loads and + sets up each high-level feature module. + +### Module boundaries + +- `lisp/ss-core.el` + Owns Emacs version checks, package bootstrap, shared constants and helper + functions, note-system paths, and small shared editor defaults that other + modules depend on. +- `lisp/ss-ui.el` + Owns theme, fonts, frame behavior, modeline, and terminal-versus-GUI UI + setup. +- `lisp/ss-org.el` + Owns base Org configuration, shared Org helpers, startup MOC behavior, and + note-opening helpers. +- `lisp/ss-agenda.el` + Owns agenda file discovery rules and agenda command setup. +- `lisp/ss-capture.el` + Owns journal capture helpers and `org-capture` templates. +- `lisp/ss-denote.el` + Owns Denote configuration and note creation helpers. +- `lisp/ss-crm.el` + Owns all people CRM logic, including parsing, cache management, abbrevs, + CAPF, lookup, open/find/insert/add/report commands, and prompt helpers. +- `lisp/ss-gptel.el` + Owns the experimental `gptel` integration. +- `lisp/ss-keys.el` + Owns central global keybindings only. + +### Dependency shape + +- `ss-core` is the only unconditional module. +- Other modules may depend on `ss-core` helpers and shared path constants. +- `ss-keys` binds commands provided by other modules but should not implement + workflow logic itself. +- Side effects should happen in each module's `ss-...-setup` function rather + than during `require`, except for definitions that are harmless at load time. + +## Migration decisions + +- Remove `config.org` entirely after extraction. There will be no transitional + dual system and no compatibility tangling layer. +- Keep `build` only if it remains useful for the modular setup; otherwise + remove or repurpose it truthfully. +- Keep `reset` only if it still aligns with the hand-edited architecture; + update it so it no longer treats `init.el` or `early-init.el` as generated + artifacts. + +## Validation + +- Primary validation: `emacs --batch -Q --load ./init.el` +- If the refactor preserves terminal-specific UI behavior, also verify in a + real `emacs -nw` session when practical. +- Documentation must be updated in the same change so `README.md` and + `AGENTS.md` describe the modular architecture truthfully. diff --git a/docs/plans/2026-04-09-modular-emacs-refactor.md b/docs/plans/2026-04-09-modular-emacs-refactor.md new file mode 100644 index 0000000..ad44ca1 --- /dev/null +++ b/docs/plans/2026-04-09-modular-emacs-refactor.md @@ -0,0 +1,152 @@ +# Modular Emacs Refactor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the literate `config.org` runtime with a modular hand-edited Emacs configuration centered on `init.el`, `early-init.el`, and `lisp/ss-*.el`. + +**Architecture:** Keep `early-init.el` minimal, move reusable runtime logic into domain modules under `lisp/`, and make `init.el` the single composition layer that enables features explicitly and calls one setup function per module. Preserve current behavior unless a structural refactor requires a minimal change. + +**Tech Stack:** Emacs Lisp, built-in `package.el`, `use-package`, Org mode, Denote, gptel + +--- + +### Task 1: Create the modular file layout + +**Files:** +- Create: `early-init.el` +- Create: `init.el` +- Create: `lisp/ss-core.el` +- Create: `lisp/ss-ui.el` +- Create: `lisp/ss-org.el` +- Create: `lisp/ss-agenda.el` +- Create: `lisp/ss-capture.el` +- Create: `lisp/ss-denote.el` +- Create: `lisp/ss-crm.el` +- Create: `lisp/ss-gptel.el` +- Create: `lisp/ss-keys.el` + +**Step 1: Create `early-init.el` with only early frame settings** + +Write the standalone early startup file and keep it limited to frame defaults +that previously had to exist before the first GUI frame. + +**Step 2: Create `init.el` as the composition root** + +Add `lisp/` to `load-path`, define `ss-enabled-features`, require `ss-core`, +and conditionally require each optional module before calling its setup +function. + +**Step 3: Create module skeletons** + +Add one `ss-...-setup` function per module and provide each feature. + +### Task 2: Extract shared runtime and UI behavior + +**Files:** +- Modify: `lisp/ss-core.el` +- Modify: `lisp/ss-ui.el` + +**Step 1: Move version checks, package bootstrap, shared paths, and editing defaults into `ss-core.el`** + +Keep shared constants and helper functions in one place and avoid hidden +cross-module state. + +**Step 2: Move theme, fonts, frame behavior, and modeline setup into `ss-ui.el`** + +Preserve the current GUI-versus-terminal behavior and keep side effects in +`ss-ui-setup`. + +### Task 3: Extract Org, agenda, capture, and Denote domains + +**Files:** +- Modify: `lisp/ss-org.el` +- Modify: `lisp/ss-agenda.el` +- Modify: `lisp/ss-capture.el` +- Modify: `lisp/ss-denote.el` + +**Step 1: Move shared Org paths and note helpers into `ss-org.el`** + +Keep `~/org` invariants unchanged and preserve startup MOC behavior. + +**Step 2: Move agenda discovery and agenda command wiring into `ss-agenda.el`** + +Preserve explicit include and exclude rules. + +**Step 3: Move journal capture helpers and `org-capture` templates into `ss-capture.el`** + +Keep the existing templates and journal structure intact. + +**Step 4: Move Denote setup into `ss-denote.el`** + +Preserve prompts, keywords, and key-facing commands. + +### Task 4: Extract CRM and gptel domains + +**Files:** +- Modify: `lisp/ss-crm.el` +- Modify: `lisp/ss-gptel.el` + +**Step 1: Move all CRM logic into `ss-crm.el`** + +Keep parsing, cache invalidation, abbrevs, CAPF, reports, and commands +together in the CRM module only. + +**Step 2: Move experimental Copilot-backed gptel setup into `ss-gptel.el`** + +Preserve existing commands and defaults. + +### Task 5: Centralize keybindings + +**Files:** +- Modify: `lisp/ss-keys.el` + +**Step 1: Bind the existing workflow commands in one place** + +Move global bindings out of the feature modules so feature inclusion remains +centralized and explicit. + +### Task 6: Update scripts and documentation + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify or delete: `build` +- Modify: `reset` +- Delete: `config.org` + +**Step 1: Rewrite documentation to describe the modular architecture truthfully** + +Remove stale references to tangling, generated startup files, and `config.org` +as the source of truth. + +**Step 2: Update helper scripts** + +Remove or rewrite anything that only made sense for the literate build path. + +**Step 3: Remove `config.org`** + +Delete the literate source once the extracted runtime files are in place. + +### Task 7: Validate the new startup path + +**Files:** +- Verify: `init.el` +- Verify: `early-init.el` +- Verify: `lisp/ss-*.el` + +**Step 1: Run batch load verification** + +Run: `emacs --batch -Q --load ./init.el` + +Expected: startup completes without load errors. + +**Step 2: Run a terminal startup check if practical** + +Run: `emacs -nw` + +Expected: terminal UI behavior still matches the previous configuration. + +**Step 3: Summarize validation and any intentional behavior changes** + +Note any small structural changes required by the refactor, and call out +whether `README.md` and `AGENTS.md` were updated. diff --git a/early-init.el b/early-init.el new file mode 100644 index 0000000..dcf868c --- /dev/null +++ b/early-init.el @@ -0,0 +1,14 @@ +;;; early-init.el --- Early startup settings -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Keep this file limited to settings that must exist before the first frame. + +;;; Code: + +(dolist (parameter '((width . 140) + (height . 42))) + (add-to-list 'default-frame-alist parameter) + (add-to-list 'initial-frame-alist parameter)) + +;;; early-init.el ends here @@ -0,0 +1,53 @@ +;;; init.el --- Main Emacs entry point -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Hand-edited runtime entry point for this Emacs configuration. + +;;; Code: + +(add-to-list + 'load-path + (expand-file-name "lisp" (file-name-directory (or load-file-name user-init-file)))) + +(setq ss-enabled-features + '(ui org agenda denote capture crm gptel keys)) + +(require 'ss-core) +(ss-core-setup) + +(when (memq 'ui ss-enabled-features) + (require 'ss-ui) + (ss-ui-setup)) + +(when (memq 'org ss-enabled-features) + (require 'ss-org) + (ss-org-setup)) + +(when (memq 'agenda ss-enabled-features) + (require 'ss-agenda) + (ss-agenda-setup)) + +(when (memq 'denote ss-enabled-features) + (require 'ss-denote) + (ss-denote-setup)) + +(when (memq 'capture ss-enabled-features) + (require 'ss-capture) + (ss-capture-setup)) + +(when (memq 'crm ss-enabled-features) + (require 'ss-crm) + (ss-crm-setup)) + +(when (memq 'gptel ss-enabled-features) + (require 'ss-gptel) + (ss-gptel-setup)) + +(when (memq 'keys ss-enabled-features) + (require 'ss-keys) + (ss-keys-setup)) + +(ss-core-load-custom-file) + +;;; init.el ends here diff --git a/lisp/ss-agenda.el b/lisp/ss-agenda.el new file mode 100644 index 0000000..a89a52e --- /dev/null +++ b/lisp/ss-agenda.el @@ -0,0 +1,40 @@ +;;; ss-agenda.el --- Agenda configuration -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Agenda discovery and agenda commands. + +;;; Code: + +(require 'ss-core) +(require 'ss-org) + +(defun ss-refresh-org-agenda-files (&rest _) + "Refresh `org-agenda-files' from the journal and PARA directories. +Ignore any arguments passed by advice wrappers." + (require 'org-agenda) + (setq org-agenda-files + (sort + (delete-dups + (append + (list (ss-require-existing-file ss-journal-file)) + (apply #'append + (mapcar (lambda (directory) + (directory-files-recursively + (ss-require-existing-directory directory) + "\\.org\\'")) + ss-org-agenda-directories)))) + #'string<))) + +(defun ss-open-agenda () + "Refresh agenda files and invoke `org-agenda'." + (interactive) + (call-interactively #'org-agenda)) + +(defun ss-agenda-setup () + "Initialize agenda behavior." + (advice-add 'org-agenda :before #'ss-refresh-org-agenda-files)) + +(provide 'ss-agenda) + +;;; ss-agenda.el ends here diff --git a/lisp/ss-capture.el b/lisp/ss-capture.el new file mode 100644 index 0000000..f930716 --- /dev/null +++ b/lisp/ss-capture.el @@ -0,0 +1,175 @@ +;;; ss-capture.el --- Capture configuration -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Journal capture helpers and capture templates. + +;;; Code: + +(require 'calendar) +(require 'org) +(require 'org-capture) +(require 'ss-core) +(require 'ss-org) + +(defun ss-journal-capture-time () + "Return the effective timestamp for the current journal capture." + (or org-overriding-default-time + (org-capture-get :default-time) + (current-time))) + +(defun ss-journal-calendar-date (&optional time) + "Return TIME as a Gregorian date list for datetree helpers." + (calendar-gregorian-from-absolute + (time-to-days (or time (current-time))))) + +(defun ss-journal-year-heading (&optional time) + "Return the journal year heading text for TIME." + (format-time-string "%Y" (or time (current-time)))) + +(defun ss-journal-day-heading (&optional time) + "Return the journal day heading text for TIME." + (format-time-string "%Y-%m-%d %A" (or time (current-time)))) + +(defun ss-journal-find-or-create-heading (level heading) + "Move to HEADING at LEVEL, creating it when missing." + (goto-char (point-min)) + (if (re-search-forward + (format "^%s %s$" + (make-string level ?*) + (regexp-quote heading)) + nil t) + (goto-char (match-beginning 0)) + (goto-char (point-max)) + (unless (bolp) + (insert "\n")) + (insert (make-string level ?*) " " heading "\n") + (forward-line -1)) + (org-back-to-heading t)) + +(defun ss-journal-goto-date (&optional time create) + "Move to TIME's journal date heading. +When CREATE is non-nil, create the datetree entry when missing." + (goto-char (point-min)) + (if create + (let ((year-heading (ss-journal-year-heading time)) + (day-heading (ss-journal-day-heading time))) + (ss-journal-find-or-create-heading 1 year-heading) + (save-restriction + (org-narrow-to-subtree) + (ss-journal-find-or-create-heading 2 day-heading)) + t) + (when (re-search-forward + (format "^\\*\\* %s$" + (regexp-quote + (ss-journal-day-heading (or time (current-time))))) + nil t) + (goto-char (match-beginning 0)) + t))) + +(defun ss-journal-ensure-day-sections () + "Ensure the standard section headings exist under the current journal day." + (org-back-to-heading t) + (let ((section-level (1+ (org-outline-level)))) + (save-excursion + (save-restriction + (org-narrow-to-subtree) + (dolist (section ss-journal-section-headings) + (goto-char (point-min)) + (unless (org-find-exact-headline-in-buffer section) + (goto-char (point-max)) + (unless (bolp) + (insert "\n")) + (insert (make-string section-level ?*) " " section "\n"))))))) + +(defun ss-journal-goto-section (section &optional time) + "Move to SECTION beneath TIME's journal date, creating structure as needed." + (unless (member section ss-journal-section-headings) + (user-error "Unknown journal section: %s" section)) + (ss-journal-goto-date time 'create) + (ss-journal-ensure-day-sections) + (let ((section-level (1+ (org-outline-level))) + position) + (save-restriction + (org-narrow-to-subtree) + (goto-char (point-min)) + (when (re-search-forward + (format "^%s %s$" + (make-string section-level ?*) + (regexp-quote section)) + nil t) + (setq position (match-beginning 0)))) + (unless position + (user-error "Journal section not found: %s" section)) + (goto-char position) + (org-back-to-heading t))) + +(defun ss-journal-capture-target (section) + "Select SECTION under today's journal datetree entry for capture." + (set-buffer (find-file-noselect (ss-require-existing-file ss-journal-file))) + (widen) + (ss-journal-goto-section section (ss-journal-capture-time))) + +(defun ss-capture--denote-templates () + "Return Denote-backed capture templates when Denote is enabled." + (when (ss-feature-enabled-p 'denote) + `(("n" "Denote") + ("nn" "Generic" plain + (file denote-last-path) + (function + (lambda () + (denote-org-capture-with-prompts :title :keywords :subdirectory))) + :no-save t + :immediate-finish nil + :kill-buffer t + :jump-to-captured t) + ("np" "Project" plain + (file denote-last-path) + (function + (lambda () + (ss-denote-capture-in-directory + ss-org-projects-directory '("project") :title :keywords :subdirectory))) + :no-save t + :immediate-finish nil + :kill-buffer t + :jump-to-captured t) + ("na" "Area" plain + (file denote-last-path) + (function + (lambda () + (ss-denote-capture-in-directory + ss-org-areas-directory nil :title :keywords :subdirectory))) + :no-save t + :immediate-finish nil + :kill-buffer t + :jump-to-captured t) + ("nr" "Resource" plain + (file denote-last-path) + (function + (lambda () + (ss-denote-capture-in-directory + ss-org-resources-directory nil :title :keywords :subdirectory))) + :no-save t + :immediate-finish nil + :kill-buffer t + :jump-to-captured t)))) + +(defun ss-capture-setup () + "Initialize capture templates." + (setq org-capture-templates + (append + '(("j" "Journal") + ("jt" "Task" entry + (function (lambda () (ss-journal-capture-target "Tasks"))) + "* TODO %?") + ("jn" "Note" entry + (function (lambda () (ss-journal-capture-target "Notes"))) + "* %?") + ("jm" "Meeting" entry + (function (lambda () (ss-journal-capture-target "Meetings"))) + "* <%<%Y-%m-%d %H:%M>> %?")) + (ss-capture--denote-templates)))) + +(provide 'ss-capture) + +;;; ss-capture.el ends here diff --git a/lisp/ss-core.el b/lisp/ss-core.el new file mode 100644 index 0000000..3be0711 --- /dev/null +++ b/lisp/ss-core.el @@ -0,0 +1,147 @@ +;;; ss-core.el --- Shared core setup -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Shared startup helpers, package bootstrap, paths, and editor defaults. + +;;; Code: + +(require 'subr-x) + +(defconst ss-minimum-emacs-version "27.1" + "Minimum supported Emacs version.") + +(defconst ss-warning-emacs-version "28.1" + "Version threshold for compatibility warnings.") + +(defconst ss-spell-check-support-enabled nil) +(defconst ss-is-windows (memq system-type '(windows-nt ms-dos cygwin))) +(defconst ss-is-linux (eq system-type 'gnu/linux)) +(defconst ss-is-mac (eq system-type 'darwin)) + +(defconst ss-org-directory (expand-file-name "~/org/") + "Root directory for Org files.") + +(defconst ss-journal-file (expand-file-name "journal.org" ss-org-directory) + "Single-file work journal for operational capture.") + +(defconst ss-org-projects-directory (expand-file-name "projects/" ss-org-directory) + "Directory for project notes.") + +(defconst ss-org-areas-directory (expand-file-name "areas/" ss-org-directory) + "Directory for area notes.") + +(defconst ss-org-resources-directory (expand-file-name "resources/" ss-org-directory) + "Directory for resource notes.") + +(defconst ss-org-archives-directory (expand-file-name "archives/" ss-org-directory) + "Directory for archived notes.") + +(defconst ss-moc-file (expand-file-name "moc.org" ss-org-directory) + "Central MOC note.") + +(defconst ss-crm-file (expand-file-name "areas/people/people.org" ss-org-directory) + "Single source of truth for the people CRM.") + +(defconst ss-journal-section-headings + '("Tasks" "Notes" "Meetings") + "Per-day section headings maintained under each journal datetree entry.") + +(defconst ss-org-agenda-directories + (list ss-org-projects-directory + ss-org-areas-directory + ss-org-resources-directory) + "Directories whose Org files feed the agenda.") + +(defun ss-feature-enabled-p (feature) + "Return non-nil when FEATURE is enabled in `ss-enabled-features'." + (memq feature ss-enabled-features)) + +(defun ss-require-existing-directory (directory) + "Return DIRECTORY, signaling when it does not exist." + (unless (file-directory-p directory) + (user-error "Directory does not exist: %s" directory)) + directory) + +(defun ss-require-existing-file (file) + "Return FILE, signaling when it does not exist." + (unless (file-exists-p file) + (user-error "File does not exist: %s" file)) + file) + +(defun ss-enable-prose-abbrev-mode () + "Enable abbrev mode in prose buffers. +We keep this mode-local so code buffers stay on their own completion rules." + (abbrev-mode 1)) + +(defun ss-core-setup () + "Initialize shared core behavior." + (let ((minver ss-minimum-emacs-version)) + (when (version< emacs-version minver) + (error "Your Emacs is too old -- this config requires v%s or higher" minver))) + (when (version< emacs-version ss-warning-emacs-version) + (message + (concat + "Your Emacs is old, and some functionality in this config will be " + "disabled. Please upgrade if possible."))) + + ;; Keep custom-set-variables out of the main config. + (setq custom-file (expand-file-name "custom.el" user-emacs-directory)) + + (require 'package) + (setq package-archives + (append '(("melpa" . "https://melpa.org/packages/")) + package-archives) + package-archive-priorities '(("gnu" . 10) + ("nongnu" . 8) + ("melpa" . 5)) + package-install-upgrade-built-in t + use-package-always-ensure nil) + (package-initialize) + (require 'use-package) + (require 'abbrev) + + (set-language-environment "UTF-8") + (set-default-coding-systems 'utf-8) + (prefer-coding-system 'utf-8) + + (setq abbrev-file-name (expand-file-name "abbrev_defs" user-emacs-directory) + save-abbrevs 'silently) + (when (file-exists-p abbrev-file-name) + (quietly-read-abbrev-file abbrev-file-name)) + + (dolist (hook '(text-mode-hook org-mode-hook)) + (add-hook hook #'ss-enable-prose-abbrev-mode)) + + (setq auto-save-default nil + backup-inhibited t + echo-keystrokes 0.1 + compilation-ask-about-save nil + mouse-wheel-scroll-amount '(1 ((shift) . 1)) + mouse-wheel-progressive-speed nil + mouse-wheel-follow-mouse t + scroll-step 1 + scroll-conservatively 101 + enable-recursive-minibuffers t + gc-cons-threshold (* 128 1024 1024) + read-process-output-max (* 4 1024 1024) + process-adaptive-read-buffering nil) + + (fset 'yes-or-no-p 'y-or-n-p) + (global-auto-revert-mode 1) + (delete-selection-mode 1) + + (setq-default indent-tabs-mode nil + fill-column 80 + tab-width 2 + indicate-empty-lines t + sentence-end-double-space nil)) + +(defun ss-core-load-custom-file () + "Load `custom-file' when it exists." + (when (file-exists-p custom-file) + (load custom-file nil 'nomessage))) + +(provide 'ss-core) + +;;; ss-core.el ends here diff --git a/lisp/ss-crm.el b/lisp/ss-crm.el new file mode 100644 index 0000000..c9b8c16 --- /dev/null +++ b/lisp/ss-crm.el @@ -0,0 +1,559 @@ +;;; ss-crm.el --- People CRM -*- lexical-binding: t; -*- + +;;; Commentary: + +;; People CRM parsing, lookup, completion, insertion, and reporting. + +;;; Code: + +(require 'abbrev) +(require 'org) +(require 'org-element) +(require 'seq) +(require 'ss-core) +(require 'subr-x) +(require 'marginalia nil t) + +(defconst ss-crm-engagement-options + '("Perm" "SOW" "SOW Fixed Outcome" "NCS India") + "Canonical engagement values for people cards.") + +(defconst ss-crm-supplier-options + '("Accenture Song" + "INFOSYS TECHNOLOGIES LIMITED" + "MAKK Integrations Pty Ltd" + "NCSI Technologies India Private Limited" + "TECH MAHINDRA LTD") + "Canonical supplier values for people cards.") + +(defvar ss-crm--cache nil + "Cached CRM entries loaded from `ss-crm-file'.") + +(defvar ss-crm--cache-mtime nil + "Modification time of the cached CRM entries.") + +(defun ss-crm--entry-name (entry) + "Return the canonical name in ENTRY." + (plist-get entry :name)) + +(defun ss-crm--entry-abbrev (entry) + "Return the abbrev trigger in ENTRY." + (plist-get entry :abbrev)) + +(defun ss-crm--entry-aliases (entry) + "Return alias variants in ENTRY." + (plist-get entry :aliases)) + +(defun ss-crm--entry-role (entry) + "Return the role in ENTRY." + (plist-get entry :role)) + +(defun ss-crm--entry-team (entry) + "Return the team in ENTRY." + (plist-get entry :team)) + +(defun ss-crm--entry-manager (entry) + "Return the manager in ENTRY." + (plist-get entry :manager)) + +(defun ss-crm--entry-engagement (entry) + "Return the engagement in ENTRY." + (plist-get entry :engagement)) + +(defun ss-crm--entry-supplier (entry) + "Return the supplier in ENTRY." + (plist-get entry :supplier)) + +(defun ss-crm--entry-location (entry) + "Return the location in ENTRY." + (plist-get entry :location)) + +(defun ss-crm--entry-current-focus (entry) + "Return the current focus in ENTRY." + (plist-get entry :current-focus)) + +(defun ss-crm-default-abbrev (name) + "Suggest a short abbrev trigger for NAME." + (let* ((parts (split-string (string-trim name) "[[:space:]]+" t)) + (first (downcase (substring (car parts) 0 (min 2 (length (car parts)))))) + (last (downcase (substring (car (last parts)) 0 1)))) + (if (> (length parts) 1) + (concat ";" first last) + (concat ";" first)))) + +(defun ss-crm--split-values (value) + "Split VALUE on commas and trim each item." + (when (and value (not (string-empty-p value))) + (seq-filter + (lambda (string) + (not (string-empty-p string))) + (mapcar #'string-trim (split-string value "," t))))) + +(defun ss-crm--summary (entry) + "Return the compact one-line summary for ENTRY." + (string-join + (seq-filter + (lambda (string) + (and string (not (string-empty-p string)))) + (list (ss-crm--entry-role entry) + (ss-crm--entry-team entry) + (ss-crm--entry-engagement entry) + (ss-crm--entry-current-focus entry))) + " | ")) + +(defun ss-crm--display (entry) + "Return the compact display string for ENTRY." + (let ((summary (ss-crm--summary entry))) + (if (string-empty-p summary) + (ss-crm--entry-name entry) + (format "%s %s" (ss-crm--entry-name entry) summary)))) + +(defun ss-crm--property-line (key value) + "Return an Org property line for KEY and VALUE." + (if (and value (not (string-empty-p value))) + (format ":%s: %s\n" key value) + "")) + +(defun ss-crm--require-file () + "Return `ss-crm-file', signaling when it is unavailable." + (ss-require-existing-file ss-crm-file)) + +(defun ss-crm--parse-entry-at-point (headline) + "Return the CRM entry described by HEADLINE at point." + (list :name (org-element-property :raw-value headline) + :abbrev (org-entry-get nil "ABBREV") + :aliases (ss-crm--split-values (org-entry-get nil "ALIASES")) + :role (org-entry-get nil "ROLE") + :team (org-entry-get nil "TEAM") + :manager (org-entry-get nil "MANAGER") + :engagement (org-entry-get nil "ENGAGEMENT") + :supplier (org-entry-get nil "SUPPLIER") + :location (org-entry-get nil "LOCATION") + :current-focus (org-entry-get nil "CURRENT_FOCUS"))) + +(defun ss-crm--parse-entries () + "Parse top-level CRM entries from `ss-crm-file'." + (with-temp-buffer + (insert-file-contents (ss-crm--require-file)) + ;; Parse cards without running user hooks; otherwise the CRM's own Org + ;; hooks recurse back into this parser. + (delay-mode-hooks + (org-mode)) + (let ((ast (org-element-parse-buffer)) + cards) + (org-element-map ast 'headline + (lambda (headline) + (when (= 1 (org-element-property :level headline)) + (goto-char (org-element-property :begin headline)) + (push (ss-crm--parse-entry-at-point headline) cards)))) + (sort cards + (lambda (left right) + (string< (ss-crm--entry-name left) + (ss-crm--entry-name right))))))) + +(defun ss-crm-entries () + "Return top-level people cards from `ss-crm-file'." + (let* ((file (ss-crm--require-file)) + (attributes (file-attributes file)) + (mtime (file-attribute-modification-time attributes))) + (unless (and ss-crm--cache + (equal mtime ss-crm--cache-mtime)) + (setq ss-crm--cache (ss-crm--parse-entries) + ss-crm--cache-mtime mtime)) + ss-crm--cache)) + +(defun ss-crm-reload () + "Reload the people cache and refresh prose buffers." + (interactive) + (setq ss-crm--cache nil + ss-crm--cache-mtime nil) + (ss-crm-refresh-buffers) + (message "Reloaded people CRM")) + +(defun ss-crm--entry-by-name (name) + "Return the people entry matching canonical NAME." + (seq-find + (lambda (entry) + (string= name (ss-crm--entry-name entry))) + (ss-crm-entries))) + +(defun ss-crm--search-keys (entry) + "Return canonical and alias search keys for ENTRY." + (cons (ss-crm--entry-name entry) + (ss-crm--entry-aliases entry))) + +(defun ss-crm--match-p (query entry) + "Return non-nil when QUERY matches ENTRY name or aliases." + (let* ((parts (split-string (downcase (string-trim query)) "[[:space:]]+" t)) + (keys (mapcar #'downcase (ss-crm--search-keys entry)))) + (seq-every-p + (lambda (part) + (seq-some + (lambda (key) + (string-match-p (regexp-quote part) key)) + keys)) + parts))) + +(defun ss-crm--matching-entries (query) + "Return entries whose canonical name or aliases match QUERY." + (let ((entries (ss-crm-entries))) + (if (string-empty-p (string-trim query)) + entries + (seq-filter + (lambda (entry) + (ss-crm--match-p query entry)) + entries)))) + +(defun ss-crm--completion-table (string pred action) + "Complete canonical people names while matching aliases via STRING." + (if (eq action 'metadata) + '(metadata (category . ss-person)) + (complete-with-action + action + (mapcar #'ss-crm--entry-name (ss-crm--matching-entries string)) + string + pred))) + +(defun ss-crm-marginalia-annotator (candidate) + "Return a Marginalia annotation for person CANDIDATE." + (when-let ((entry (ss-crm--entry-by-name candidate))) + (concat " " (ss-crm--summary entry)))) + +(defun ss-crm-select-entry (&optional prompt) + "Select a person entry using PROMPT." + (let ((completion-extra-properties + '(:annotation-function ss-crm-marginalia-annotator))) + (ss-crm--entry-by-name + (completing-read (or prompt "Person: ") + #'ss-crm--completion-table + nil + t)))) + +(defun ss-crm-overview () + "Open `ss-crm-file' in overview mode, widening first when needed." + (interactive) + (unless (and buffer-file-name + (string= (file-truename buffer-file-name) + (file-truename ss-crm-file))) + (find-file (ss-crm--require-file))) + (widen) + (goto-char (point-min)) + (org-overview) + (org-cycle-hide-drawers 'all)) + +(defun ss-crm-open () + "Open the people CRM by delegating to `ss-crm-overview'." + (interactive) + (ss-crm-overview)) + +(defun ss-crm--track-buffer () + "Refresh CRM caches when `ss-crm-file' is saved." + (when (and buffer-file-name + (string= (file-truename buffer-file-name) + (file-truename ss-crm-file))) + (add-hook 'after-save-hook #'ss-crm-reload nil t))) + +(defun ss-crm--source-buffer-p () + "Return non-nil when the current buffer visits `ss-crm-file'." + (and buffer-file-name + (string= (file-truename buffer-file-name) + (file-truename ss-crm-file)))) + +(defun ss-crm--open-entry (entry) + "Open the people CRM file, then narrow to ENTRY for card view." + (find-file (ss-crm--require-file)) + (widen) + (let ((position (org-find-exact-headline-in-buffer + (ss-crm--entry-name entry)))) + (unless position + (user-error "No people card for %s" (ss-crm--entry-name entry))) + (goto-char position)) + (org-narrow-to-subtree) + (org-fold-show-subtree) + (org-fold-show-entry) + (goto-char (point-min))) + +(defun ss-crm-find () + "Find a person and open that card." + (interactive) + (ss-crm--open-entry + (or (ss-crm-select-entry "Find person: ") + (user-error "No person selected")))) + +(defun ss-crm-insert-name () + "Insert a canonical person name at point." + (interactive) + (let ((entry (or (ss-crm-select-entry "Insert person name: ") + (user-error "No person selected")))) + (insert (ss-crm--entry-name entry)))) + +(defun ss-crm-insert-summary () + "Insert a compact person summary at point." + (interactive) + (let ((entry (or (ss-crm-select-entry "Insert person summary: ") + (user-error "No person selected")))) + (insert (ss-crm--display entry)))) + +(defun ss-crm--report-buffer (title group-fn) + "Render a grouped CRM report titled TITLE using GROUP-FN." + (let* ((entries (ss-crm-entries)) + (groups + (seq-group-by + (lambda (entry) + (let ((value (funcall group-fn entry))) + (if (string-empty-p (or value "")) + "(none)" + value))) + entries))) + (setq groups + (sort groups + (lambda (left right) + (string< (car left) (car right))))) + (with-current-buffer (get-buffer-create "*People Report*") + (let ((inhibit-read-only t)) + (erase-buffer) + (org-mode) + (insert "#+title: " title "\n\n") + (dolist (group groups) + (insert "* " (car group) "\n") + (dolist (entry (sort (copy-sequence (cdr group)) + (lambda (left right) + (string< (ss-crm--entry-name left) + (ss-crm--entry-name right))))) + (insert "- " (ss-crm--display entry) "\n"))) + (goto-char (point-min)) + (read-only-mode 1) + (view-mode 1)) + (pop-to-buffer (current-buffer))))) + +(defun ss-crm-report-by-team () + "Show people grouped by team." + (interactive) + (ss-crm--report-buffer "People by team" #'ss-crm--entry-team)) + +(defun ss-crm-report-by-manager () + "Show people grouped by manager." + (interactive) + (ss-crm--report-buffer "People by manager" #'ss-crm--entry-manager)) + +(defun ss-crm-report-by-engagement () + "Show people grouped by engagement." + (interactive) + (ss-crm--report-buffer "People by engagement" #'ss-crm--entry-engagement)) + +(defun ss-crm-report-by-supplier () + "Show non-empty suppliers grouped by supplier." + (interactive) + (let* ((entries + (seq-filter + (lambda (entry) + (not (string-empty-p (or (ss-crm--entry-supplier entry) "")))) + (ss-crm-entries))) + (groups (seq-group-by #'ss-crm--entry-supplier entries))) + (setq groups + (sort groups + (lambda (left right) + (string< (car left) (car right))))) + (with-current-buffer (get-buffer-create "*People Report*") + (let ((inhibit-read-only t)) + (erase-buffer) + (org-mode) + (insert "#+title: People by supplier\n\n") + (dolist (group groups) + (insert "* " (car group) "\n") + (dolist (entry (sort (copy-sequence (cdr group)) + (lambda (left right) + (string< (ss-crm--entry-name left) + (ss-crm--entry-name right))))) + (insert "- " (ss-crm--display entry) "\n"))) + (goto-char (point-min)) + (read-only-mode 1) + (view-mode 1)) + (pop-to-buffer (current-buffer))))) + +(defun ss-crm-report-by-role () + "Show people grouped by role." + (interactive) + (ss-crm--report-buffer "People by role" #'ss-crm--entry-role)) + +(defun ss-crm-report-by-location () + "Show people grouped by location." + (interactive) + (ss-crm--report-buffer "People by location" #'ss-crm--entry-location)) + +(defun ss-crm-read-string (prompt &optional default) + "Read PROMPT and trim the result." + (string-trim (read-string prompt nil nil default))) + +(defun ss-crm-read-required-string (prompt &optional default) + "Read PROMPT and require a non-empty result." + (let ((value (ss-crm-read-string prompt default))) + (if (string-empty-p value) + (user-error "%s is required" (string-remove-suffix ": " prompt)) + value))) + +(defun ss-crm--completion-values (extractor &optional include-empty) + "Return sorted unique values using EXTRACTOR. +When INCLUDE-EMPTY is non-nil, keep empty values." + (let ((values (delete-dups (mapcar extractor (ss-crm-entries))))) + (seq-sort + #'string< + (seq-filter + (lambda (value) + (or include-empty + (not (string-empty-p (or value ""))))) + values)))) + +(defun ss-crm-add () + "Add a new compact person card to `ss-crm-file'." + (interactive) + (let ((name (ss-crm-read-required-string "Full name: ")) + (abbrev nil) + (aliases nil) + (role nil) + (team nil) + (manager nil) + (engagement nil) + (supplier nil) + (location nil) + (current-focus nil)) + (setq abbrev (ss-crm-read-string "Abbrev: " (ss-crm-default-abbrev name)) + aliases (ss-crm-read-string "Aliases (comma-separated, optional): ") + role (completing-read "Role: " + (ss-crm--completion-values #'ss-crm--entry-role) + nil nil) + team (completing-read "Team: " + (ss-crm--completion-values #'ss-crm--entry-team) + nil nil) + manager (completing-read "Manager: " + (mapcar #'ss-crm--entry-name (ss-crm-entries)) + nil nil) + engagement (completing-read "Engagement: " + ss-crm-engagement-options nil t) + supplier (completing-read "Supplier: " + ss-crm-supplier-options nil t) + location (completing-read "Location: " + (ss-crm--completion-values #'ss-crm--entry-location) + nil nil) + current-focus (ss-crm-read-required-string "Current focus: ")) + (setq role (if (string-empty-p role) + (ss-crm-read-required-string "Role: ") + role)) + (setq team (if (string-empty-p team) + (ss-crm-read-required-string "Team: ") + team)) + (setq manager (if (string-empty-p manager) + (ss-crm-read-required-string "Manager: ") + manager)) + (setq location (if (string-empty-p location) + (ss-crm-read-required-string "Location: ") + location)) + (when (ss-crm--entry-by-name name) + (user-error "A person card for %s already exists" name)) + (when (string-empty-p abbrev) + (setq abbrev (ss-crm-default-abbrev name))) + (find-file (ss-crm--require-file)) + (widen) + (goto-char (point-max)) + (unless (bolp) + (insert "\n")) + (unless (looking-back "\n\n" nil) + (insert "\n")) + (insert "* " name "\n" + ":PROPERTIES:\n" + (ss-crm--property-line "ABBREV" abbrev) + (ss-crm--property-line "ALIASES" aliases) + (ss-crm--property-line "ROLE" role) + (ss-crm--property-line "TEAM" team) + (ss-crm--property-line "MANAGER" manager) + (ss-crm--property-line "ENGAGEMENT" engagement) + (ss-crm--property-line "SUPPLIER" supplier) + (ss-crm--property-line "LOCATION" location) + (ss-crm--property-line "CURRENT_FOCUS" current-focus) + ":END:\n\n" + "** Context\n\n" + "** TODOs\n") + (save-buffer) + (ss-crm-reload) + (ss-crm--open-entry (ss-crm--entry-by-name name)))) + +(defun ss-crm--clear-installed-abbrevs () + "Remove people-specific abbrevs from the current local table." + (mapatoms + (lambda (symbol) + (when (abbrev-get symbol :ss/crm) + (define-abbrev local-abbrev-table (symbol-name symbol) nil))) + local-abbrev-table)) + +(defun ss-crm-install-abbrevs () + "Install people abbrevs into the current buffer." + (unless (ss-crm--source-buffer-p) + (setq-local local-abbrev-table (copy-abbrev-table local-abbrev-table)) + (ss-crm--clear-installed-abbrevs) + (dolist (entry (ss-crm-entries)) + (let* ((name (ss-crm--entry-name entry)) + (abbrev (ss-crm--entry-abbrev entry)) + (abbrev-name + (if (or (null abbrev) (string-empty-p abbrev)) + (ss-crm-default-abbrev name) + abbrev))) + (define-abbrev local-abbrev-table abbrev-name name) + (when-let ((abbrev-symbol + (abbrev-symbol abbrev-name local-abbrev-table))) + (abbrev-put abbrev-symbol :ss/crm t)))))) + +(defun ss-crm-refresh-buffers () + "Refresh people abbrevs in every prose buffer." + (dolist (buffer (buffer-list)) + (with-current-buffer buffer + (when (and (bound-and-true-p abbrev-mode) + (derived-mode-p 'text-mode 'org-mode)) + (ss-crm-install-abbrevs))))) + +(defun ss-crm-capf () + "Return canonical people completions at a word boundary." + (let ((end (point))) + (save-excursion + (skip-syntax-backward "w_") + (let ((beg (point))) + (when (< beg end) + (let ((annotation + (lambda (candidate) + (when-let ((entry (ss-crm--entry-by-name candidate))) + (concat " " (ss-crm--summary entry))))) + (docsig + (lambda (candidate) + (when-let ((entry (ss-crm--entry-by-name candidate))) + (ss-crm--summary entry))))) + (list beg end #'ss-crm--completion-table + :exclusive 'no + :annotation-function annotation + :company-docsig docsig))))))) + +(defun ss-enable-people-capf () + "Add `ss-crm-capf' once in prose buffers." + (unless (or (ss-crm--source-buffer-p) + (memq #'ss-crm-capf completion-at-point-functions)) + (add-hook 'completion-at-point-functions #'ss-crm-capf nil t))) + +(defun ss-crm--maybe-overview-buffer () + "Reset the people CRM buffer to overview when visiting it directly." + (when (and buffer-file-name + (string= (file-truename buffer-file-name) + (file-truename ss-crm-file))) + (widen) + (goto-char (point-min)) + (org-overview) + (org-cycle-hide-drawers 'all))) + +(defun ss-crm-setup () + "Initialize CRM hooks and helpers." + (dolist (hook '(text-mode-hook org-mode-hook)) + (add-hook hook #'ss-enable-people-capf) + (add-hook hook #'ss-crm-install-abbrevs)) + (add-hook 'find-file-hook #'ss-crm--track-buffer) + (add-hook 'find-file-hook #'ss-crm--maybe-overview-buffer)) + +(provide 'ss-crm) + +;;; ss-crm.el ends here diff --git a/lisp/ss-denote.el b/lisp/ss-denote.el new file mode 100644 index 0000000..65b9b53 --- /dev/null +++ b/lisp/ss-denote.el @@ -0,0 +1,49 @@ +;;; ss-denote.el --- Denote configuration -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Durable note creation and Denote integration. + +;;; Code: + +(require 'ss-core) + +(declare-function denote-keywords-prompt "denote") +(declare-function denote-org-capture "denote") +(declare-function denote-org-capture-with-prompts "denote") + +(defun ss-denote-capture-in-directory (directory &optional keywords &rest prompts) + "Start a Denote Org capture in DIRECTORY with KEYWORDS and PROMPTS. +If PROMPTS is empty, rely on `denote-prompts'." + (let* ((prompt-for-keywords (memq :keywords prompts)) + (directory (ss-require-existing-directory directory)) + (denote-directory directory) + (denote-use-directory (unless (memq :subdirectory prompts) directory)) + (denote-use-keywords + (if prompt-for-keywords + (delete-dups (append keywords (denote-keywords-prompt))) + keywords))) + (if prompts + (denote-org-capture-with-prompts + (memq :title prompts) + nil + (memq :subdirectory prompts) + (memq :date prompts) + (memq :template prompts)) + (denote-org-capture)))) + +(defun ss-denote-setup () + "Initialize Denote." + (use-package denote + :ensure t + :after org + :config + (setq denote-directory ss-org-directory + denote-known-keywords '("project") + denote-prompts '(title keywords) + denote-org-capture-specifiers "%?") + (denote-rename-buffer-mode 1))) + +(provide 'ss-denote) + +;;; ss-denote.el ends here diff --git a/lisp/ss-gptel.el b/lisp/ss-gptel.el new file mode 100644 index 0000000..bef5ce7 --- /dev/null +++ b/lisp/ss-gptel.el @@ -0,0 +1,21 @@ +;;; ss-gptel.el --- GPTel integration -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Experimental gptel setup using the GitHub Copilot backend. + +;;; Code: + +(require 'ss-core) + +(defun ss-gptel-setup () + "Initialize gptel." + (if (require 'gptel nil t) + (setq gptel-default-mode 'org-mode + gptel-model 'gpt-4o + gptel-backend (gptel-make-gh-copilot "Copilot")) + (message "Skipping gptel setup because the package is unavailable."))) + +(provide 'ss-gptel) + +;;; ss-gptel.el ends here diff --git a/lisp/ss-keys.el b/lisp/ss-keys.el new file mode 100644 index 0000000..fe45572 --- /dev/null +++ b/lisp/ss-keys.el @@ -0,0 +1,55 @@ +;;; ss-keys.el --- Global keybindings -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Centralized global keybindings for enabled features. + +;;; Code: + +(require 'ss-core) + +(defun ss-keys-setup () + "Install global keybindings for enabled features." + (when (ss-feature-enabled-p 'agenda) + (global-set-key (kbd "C-c a") #'ss-open-agenda)) + + (when (ss-feature-enabled-p 'capture) + (global-set-key (kbd "C-c c") #'org-capture)) + + (when (and (ss-feature-enabled-p 'denote) + (fboundp 'denote-open-or-create) + (fboundp 'denote-link)) + (global-set-key (kbd "C-c n n") #'denote-open-or-create) + (global-set-key (kbd "C-c n l") #'denote-link)) + + (when (ss-feature-enabled-p 'org) + (global-set-key (kbd "C-c n M") #'ss-open-moc) + (global-set-key (kbd "C-c n d") #'ss-open-journal)) + + (when (ss-feature-enabled-p 'crm) + (global-set-key (kbd "C-c n E") #'ss-crm-report-by-engagement) + (global-set-key (kbd "C-c n f") #'ss-crm-find) + (global-set-key (kbd "C-c n i") #'ss-crm-insert-name) + (global-set-key (kbd "C-c n I") #'ss-crm-insert-summary) + (global-set-key (kbd "C-c n L") #'ss-crm-report-by-location) + (global-set-key (kbd "C-c n o") #'ss-crm-overview) + (global-set-key (kbd "C-c n O") #'ss-crm-report-by-role) + (global-set-key (kbd "C-c n p") #'ss-crm-open) + (global-set-key (kbd "C-c n P") #'ss-crm-add) + (global-set-key (kbd "C-c n R") #'ss-crm-report-by-manager) + (global-set-key (kbd "C-c n S") #'ss-crm-report-by-supplier) + (global-set-key (kbd "C-c n T") #'ss-crm-report-by-team)) + + (when (and (ss-feature-enabled-p 'gptel) + (fboundp 'gptel) + (fboundp 'gptel-send) + (fboundp 'gptel-rewrite) + (fboundp 'gptel-add)) + (global-set-key (kbd "C-c n g") #'gptel) + (global-set-key (kbd "C-c n s") #'gptel-send) + (global-set-key (kbd "C-c n r") #'gptel-rewrite) + (global-set-key (kbd "C-c n a") #'gptel-add))) + +(provide 'ss-keys) + +;;; ss-keys.el ends here diff --git a/lisp/ss-org.el b/lisp/ss-org.el new file mode 100644 index 0000000..30956e9 --- /dev/null +++ b/lisp/ss-org.el @@ -0,0 +1,57 @@ +;;; ss-org.el --- Base Org configuration -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Shared Org setup and note-opening helpers. + +;;; Code: + +(require 'ss-core) + +(defun ss-open-journal () + "Open `ss-journal-file', moving to today's entry when it exists." + (interactive) + (find-file (ss-require-existing-file ss-journal-file)) + (widen) + (unless (fboundp 'ss-journal-goto-date) + (goto-char (point-max))) + (when (fboundp 'ss-journal-goto-date) + (unless (ss-journal-goto-date) + (goto-char (point-max))))) + +(defun ss-open-moc () + "Open the central MOC note." + (interactive) + (find-file (ss-require-existing-file ss-moc-file))) + +(defun ss-org-setup () + "Initialize base Org configuration." + (use-package org + :ensure nil + :config + (setq org-directory ss-org-directory + org-hide-emphasis-markers t + org-agenda-search-headline-for-time t) + (add-hook 'org-mode-hook + (lambda () + (setq-local org-hide-emphasis-markers t) + (font-lock-flush) + (font-lock-ensure)))) + + (use-package git-auto-commit-mode + :ensure t + :pin melpa + :commands (git-auto-commit-mode) + :init + (setq gac-shell-and + (if (string-match-p "fish\\'" shell-file-name) + " ; and " + " && "))) + + (add-hook 'emacs-startup-hook + (lambda () + (find-file (ss-require-existing-file ss-moc-file))))) + +(provide 'ss-org) + +;;; ss-org.el ends here diff --git a/lisp/ss-ui.el b/lisp/ss-ui.el new file mode 100644 index 0000000..79f2a0d --- /dev/null +++ b/lisp/ss-ui.el @@ -0,0 +1,135 @@ +;;; ss-ui.el --- Interface configuration -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Theme, frame, completion, and interface setup. + +;;; Code: + +(require 'ss-core) + +(defun ss-ui--configure-frames () + "Apply GUI and terminal frame behavior." + (when (display-graphic-p) + (set-frame-size (selected-frame) 140 42) + (menu-bar-mode -1) + (tool-bar-mode -1) + (scroll-bar-mode -1) + (tooltip-mode -1) + (set-face-attribute + 'default nil + :family "JetBrains Mono" :height 140 :weight 'medium) + (set-face-attribute + 'fixed-pitch nil + :family "JetBrains Mono" :weight 'medium) + (set-face-attribute + 'fixed-pitch-serif nil + :family "JetBrains Mono" :weight 'medium)) + + (unless (display-graphic-p) + ;; Terminal menu bar removal stays on startup hook to avoid tty regressions. + (add-hook 'emacs-startup-hook (lambda () (menu-bar-mode -1))))) + +(defun ss-ui--setup-modeline () + "Configure the modeline." + (use-package time + :ensure nil + :config + (setq display-time-24hr-format t + display-time-day-and-date t + display-time-default-load-average nil + calendar-latitude -37.7667 + calendar-longitude 145.0 + calendar-location-name "Melbourne, VIC") + (display-time-mode 1)) + + ;; Keep the theme's faces, but make the right edge alignment dynamic. + (setq-default mode-line-format + (list + " " + "%e" + mode-line-front-space + mode-line-mule-info + mode-line-client + mode-line-modified + mode-line-remote + mode-line-frame-identification + mode-line-buffer-identification + " " + mode-line-position + '(vc-mode vc-mode) + " " + mode-line-modes + '(:eval (propertize + " " + 'display + `((space :align-to + (- right + ,(+ 2 (string-width + (format-mode-line mode-line-misc-info)))))))) + mode-line-misc-info + " " + mode-line-end-spaces))) + +(defun ss-ui--setup-completion () + "Configure minibuffer and in-buffer completion." + (use-package vertico + :ensure t + :pin melpa + :init + (vertico-mode 1)) + + (use-package orderless + :ensure t + :pin melpa + :custom + (completion-styles '(orderless basic)) + (completion-category-defaults nil) + (completion-category-overrides '((file (styles basic partial-completion))))) + + (use-package marginalia + :ensure t + :pin melpa + :after vertico + :init + (marginalia-mode 1)) + + (use-package corfu + :ensure t + :pin gnu + :init + (global-corfu-mode 1))) + +(defun ss-ui-setup () + "Initialize interface and completion behavior." + (setq inhibit-startup-message t + inhibit-startup-screen t + ring-bell-function 'ignore) + + (ss-ui--configure-frames) + + (use-package modus-themes + :ensure nil + :no-require t + :config + (load-theme 'modus-vivendi t)) + + (use-package dired + :ensure nil + :custom + (dired-use-ls-dired nil)) + + (line-number-mode 1) + (column-number-mode 1) + (show-paren-mode 1) + + (setq-default indicate-empty-lines nil) + (setq-default indicate-buffer-boundaries nil) + (setq-default fringe-indicator-alist nil) + + (ss-ui--setup-modeline) + (ss-ui--setup-completion)) + +(provide 'ss-ui) + +;;; ss-ui.el ends here @@ -2,5 +2,5 @@ set -eu -rm -f init.el early-init.el custom.el +rm -f custom.el rm -rf auto-save-list eln-cache elpa |
