aboutsummaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* feat(keys): dedicated views group, de-overloaded fetch, account bindingCraig Jennings2026-06-024-73/+154
| | | | | | | | | | The C-; L keymap and the transient menu had drifted into two different letter schemes for the same commands, the fetch sub-map carried both issue-fetching and view-movement, and pearl-switch-account was bound nowhere. Give views their own group under v: l/L run local/Linear, c/e/k create/edit/delete, u/U publish local/current, d save a Linear view locally. Fetch (f) is now issue sources only. A new workspace group (w) binds switch-account plus the setup commands, which previously lived only in the transient. v D is reserved, unbound, for the not-yet-built set-default-view. The transient already had an issue-only fetch group and a dedicated views group, so its changes are additive: switch-account and copy-issue-url were both missing from the menu and are added, and save/save-all/edit-description take s/S/d to match the keymap's hot keys. Matching those three cascades a few view and edit letters because the transient is a flat keyspace where every command needs a unique key. Suite 789 to 792, compile and lint clean.
* docs(filter): document multi-state selection and reconcile the views boundaryCraig Jennings2026-06-012-7/+7
| | | | | | | | I documented the new multi-state capability and brought the views spec and README back in sync with it. The README's filter section notes the State prompt is multi-select and shows the (:state ("Todo" "In Review")) form. The local/Linear views spec's reverse-compile dimension table, singular-vs-plural rule, and refusal list now list state.name.in as a plural-capable exception alongside :labels and :state-type, and the README copy-down paragraph no longer claims a multi-value filter on any single dimension refuses. The views vNext note records that the named-states case shipped here. Phase 3 of docs/multi-state-filter-spec.org. Docs only.
* feat(filter): multi-select states in the interactive builderCraig Jennings2026-06-012-8/+57
| | | | | | I made the builder's State prompt a completing-read-multiple, the way the Labels prompt already is, so you can pick several workflow states for one filter. The selection maps the way the spec describes: pick one and :state stays a scalar string, pick several and it becomes a list (state.name.in), pick none and there's no state constraint. The "[ Any. ]" sentinel is filtered out of the result like it is for labels. Phase 2 of docs/multi-state-filter-spec.org. A stubbed-builder test covers the one/many/none mapping. Suite, compile, and lint green.
* feat(filter): :state accepts a list of state namesCraig Jennings2026-06-013-6/+74
| | | | | | I made :state polymorphic, mirroring :state-type. A string still compiles to state.name.eq. A list now compiles to state.name.in, so (:state ("Todo" "In Review")) matches issues in either state. The reverse-compile inverts state.name.in back to a scalar (one name) or a list (several), so copy-down no longer refuses a Linear view filtering on a set of named states. The validator accepts a string or a list of non-empty names, the same shape it already enforces for :labels, and a list-valued :state keeps its precedence over :state-type and :open. Phase 1 of docs/multi-state-filter-spec.org. Suite, compile, and lint green.
* docs: spec for selecting multiple statuses in a filterCraig Jennings2026-06-011-0/+148
| | | | | | I wrote a spec for letting a filter match several workflow states at once. The builder picks one state today, so "Todo or In Review" can't be built. The design makes :state polymorphic, a name or a list of names, mirroring how :state-type already works, so (:state ("Todo" "In Review")) compiles to state.name.in. It covers the four filter-side readers (compiler, builder, validator, reverse-compile), reconciles the views spec's representability boundary, and notes the issue's own state field is a separate, unaffected use. Codex review incorporated. Status Ready.
* test(views): source-level naming regression locking out "saved query"Craig Jennings2026-06-011-0/+10
| | | | | | | | I added a test that reads pearl.el and asserts the user-facing "saved query" phrase appears nowhere, so the rename can't silently regress. Internal helpers keep the hyphenated saved-query symbol fragment, which a user never sees. The banned thing is the two-word user-facing phrase in any prompt, message, transient label, or docstring. This is the cross-cutting net over Phase 8. The per-phase tests already cover the account guard, cross-store dedup and dispatch, reverse-compile round-trips and refusals, and the metadata-preserving writer. The live create, edit, run, publish, save-locally, refuse, and delete end-to-end needs a real Linear workspace and is filed as a manual-verify checklist. Suite at 777 green, compile and lint clean.
* docs(views): update README and package summary to the views vocabularyCraig Jennings2026-06-012-37/+53
| | | | | | | | I rewrote the user-facing docs around filter / local view / Linear view, with no "query" left in the README or package summary. The keymap quick-reference shows the new bindings: run local at f l, run Linear at f v, publish at f u, save-locally at f d, and create / edit / delete local views at c v / e v / k v. The Sources section now states the source-of-truth rule: a published local view runs your local filter, not the Linear mirror, and pearl-run-linear-view runs the server-side version. The Publishing section reads "publish" instead of "sync", and a new "Saving a Linear view locally" section documents copy-down, including the representability boundary and the refuse-don't-guess behavior. A "Creating and editing local views" table covers create, edit, and save-locally, and the account guard on publish and delete is noted. Phase 7 of the views spec.
* feat(views): save a Linear view locally via the reverse-compile (copy down)Craig Jennings2026-06-014-2/+510
| | | | | | | | | | I implemented copy-down, the one direction that needed real engine work. A live probe showed CustomView.filterData is readable but stored as Linear's and/or view-filter tree, not the flat IssueFilter Pearl emits, so pearl--reverse-compile-issue-filter is a normalize-then-match: it flattens the top-level and, unwraps single-branch and/or, reads a one-element in as a scalar, then matches each conjunct to one authoring key. Every helper returns (ok . plist) or (refuse . reason). The contract follows the spec's dimension table: distinct :state and :state-type, canonical numeric :priority, :open t mapped from the exact open-state nin while generic nin refuses, and a multi-value in on any singular key refuses rather than silently narrow the view. Anything outside Pearl's AND-only model refuses with a structured reason that names the construct. pearl-save-linear-view-locally lists favorited Linear views, fetches the chosen view's filterData through a small async helper, reverse-compiles it, and saves a forked local view through pearl--save-local-view: no tracking link, :copied-from-view-id provenance only, active account stamped. On a refusal it points the user at pearl-run-linear-view; on success it states the fork's independence. Bound at f d (down) and the transient Views column. Phase 6 of the views spec. 30 tests cover per-dimension round-trips (build->encode->read bridge), normalize-from-real-JSON trees, the full refusal set, and the command-side. Suite, compile, and lint are green.
* feat(views): guard publish and delete against cross-account local viewsCraig Jennings2026-06-012-1/+41
| | | | | | | | I extended the account guard from edit and run to the two remote-mutating commands. pearl-publish-local-view now refuses a local view tagged to another account before any customViewCreate or customViewUpdate, and pearl-delete-local-view refuses before the confirm prompt or any customViewDelete. Publishing or deleting under the wrong account would resolve ids against the wrong workspace or target the wrong remote, so the guard fires at the command boundary, before Linear is touched. publish-current-view inherits the guard by delegating to publish-local-view. This completes the round-6 finding that one guard should cover every read and mutate of a local-view entry, not only edit and copy. Phase 5 of the views spec. Tests confirm both commands error on a cross-account entry and make no Linear call. Suite, compile, and lint are green.
* feat(views): rework the picker for local/Linear labels, dedup, and local โ†ตCraig Jennings2026-06-012-67/+117
| | | | | | | | | | | | dispatch I reworked pearl--pick-source-candidates for the three Phase 4 behaviors. Labels now read [local] for a private local view, [local -> Linear:SCOPE] for a published one, [linear] for a Linear view favorite, and [KIND] for a non-view favorite, so the label itself shows where a view lives. Dispatch follows the source-of-truth rule (decision 16): every local view, tracked or not, dispatches as a :type filter and runs its local authoring filter, never the Linear mirror. A tracked entry carries its :linear-view-url so the rendered buffer can still open the mirror. Before this, a published local view ran server-side, which contradicted the editable-local model. Cross-store dedup: a Linear view favorite whose customView id matches a tracked local view's :linear-view-id is dropped, so the editable local entry is the one shown. A local and a Linear view that merely share a name stay distinct. Phase 4 of the views spec. Tests cover the labels, the local-filter dispatch, and the dedup and same-name cases. Suite, compile, and lint are green.
* feat(views): reshape the keymap and transient around the view nounCraig Jennings2026-06-013-24/+36
| | | | | | | | I put the view operations under the verb a user would reach for. Create, edit, and delete local views are c v, e v, and k v. The fetch group gains run-local-view at f l and keeps run-linear-view at f v, with publish at f u and save-locally reserved at f d. Since f p already runs issues by project, publish and save-locally take u and d, which read as up and down and mirror the copy-up and copy-down lifecycle. The transient now has a Views column grouping create, edit, run-local, run-linear, publish, and delete, separate from the source-fetching commands. I updated the spec's keymap to the implemented letters so the two stay in sync. Phase 3 of the views spec. Keymap tests assert the new bindings. Suite, compile, and lint are green.
* feat(views): add create and edit local views with a metadata-preserving writerCraig Jennings2026-06-013-27/+321
| | | | | | | | | | I added pearl-create-local-view and pearl-edit-local-view, plus the writer and guards the spec calls for. pearl--save-local-view replaces the old rebuild-from-scratch writer. It copies an existing entry and updates only the filter, sort, and order, so a published view keeps its :linear-view-* tracking link and a copied-down view keeps its provenance. A new entry gets the active account stamped under pearl-accounts. pearl--require-local-view-account is the one guard every read or mutate command shares, and pearl-run-local-view now calls it instead of an inline check. pearl--resolve-local-view-name carries the Replace/Rename/Cancel collision policy for create, edit-rename, and copy-down. The ad-hoc builder's save offer routes through the preserving writer and reads "save this as a local view". Edit re-runs the builder when you choose to rebuild and otherwise keeps the stored filter. Full per-dimension pre-seeding of the builder is deferred, since it needs the id-to-name reverse resolution that copy-down's reverse-compile introduces. I filed it as a follow-up. Phase 2 of the views spec. Tests cover metadata preservation, the account guard, the collision policy, and the metadata-preserving rename. Suite, compile, and lint are green.
* refactor: rename saved-query surface to local-view vocabularyCraig Jennings2026-06-0113-243/+317
| | | | | | | | I renamed Pearl's "saved query" surface to the local/Linear view vocabulary, with no obsolete aliases, since Pearl has no users to protect. The defcustom pearl-saved-queries is now pearl-local-views. The commands pearl-run-saved-query, pearl-delete-saved-query, pearl-sync-saved-query-to-linear, pearl-run-view, and pearl-publish-current-source become pearl-run-local-view, pearl-delete-local-view, pearl-publish-local-view, pearl-run-linear-view, and pearl-publish-current-view. User-facing prompts, messages, and docstrings drop "saved query" for "local view", and the publish command reads "publish" instead of "sync". Internal GraphQL and helper names keep their "query" terms, which a user never sees. A naming-regression test asserts the new symbols exist, the old ones are gone rather than aliased, and no public command exposes "query". Phase 1 of docs/local-and-linear-views-spec.org. No behavior change. Suite, compile, and lint are green.
* docs: finalize local/Linear views spec for implementationCraig Jennings2026-06-011-82/+366
| | | | | | | | The spec is implementation-ready. It pins the design the earlier drafts left open, across the parts that turned out to need real contracts rather than prose. Copy-down's reverse-compile is the load-bearing piece. A live probe verified that CustomView.filterData is readable but stored as Linear's and/or view-filter tree, not the IssueFilter shape Pearl's compiler emits, so the reverse-compile is a normalize-then-match: flatten the tree, unwrap single-branch or/and, read one-element in as a scalar, then match each conjunct to a Pearl authoring key. The per-dimension contract is explicit, including the cases where the compiled filter loses information: :state and :state-type stay distinct, :priority canonicalizes to the integer, :open t maps from the exact open-state nin while generic nin refuses, and a multi-value in on any singular key refuses rather than silently narrow the view. Anything outside the model refuses with a structured reason instead of producing a lossy local view. The rest of the surface is settled too. A tracked local view runs the local filter, the source of truth, not its Linear mirror. Edit preserves all non-edited metadata through a dedicated writer. One account guard covers every read and mutate, with delete and publish refusing before any Linear call on a mismatch. Local and cross-store name collisions have a defined policy. Vocabulary is direct-renamed to filter / local view / Linear view, with intent-first publish and save-locally commands and no aliases. Agreed decisions, acceptance criteria, a phased plan, and the dispositions are recorded inline.
* docs: add bidirectional copy to the local/Linear views specCraig Jennings2026-06-011-26/+72
| | | | | | | | I expanded the spec to make copying a view between the two stores a first-class operation in both directions, and deliberately asymmetric. Copy up (local view to Linear view) is publish: it creates a tracked Linear view, and re-copying updates it in place. Copy down (Linear view to local view) is a fork: it duplicates the Linear view's filter into a new, independent local view you can edit and rename, with no sync link back. The principle behind the asymmetry is that the local view is the editable source of truth. Copy down is the one direction that needs real engine work. Because the result has to be editable, it can't store opaque filter JSON. It has to reverse-compile Linear's IssueFilter back into Pearl's authoring plist. The spec carries the dimension table the reverse-compile inverts, and draws the representability boundary: when a Linear view's filter uses OR logic, an unmodeled dimension, or an operator Pearl doesn't emit, copy down refuses with a clear message rather than dropping conditions silently. A local view that looks right but matches a different issue set, diverging on every refresh, is the worse failure. I also added the two decisions the change forces (refuse-don't-guess on unrepresentable filters, fork-not-track on copy down), the copy-up naming question, keymap slots, acceptance criteria for both round-trips, and two implementation phases. The spec is still a Draft, now with eight open questions.
* docs: spec for local views and Linear viewsCraig Jennings2026-06-011-0/+197
| | | | | | | | I drafted a spec that renames "saved query" to one noun with a location qualifier: a local view (private, on disk) and a Linear view (published, shared, the CustomView Linear users already know). The lifecycle is git-shaped: a local view is a local branch, a Linear view a remote branch, publish is push, and the stored view-id is the tracking link. The model already exists in the code. What's missing is a coherent name and command shape. Today the saved-query operations scatter across the verb-first keymap: create is hidden inside the filter builder and filed under fetch, delete has a home, edit doesn't exist. The spec covers the rename map (obsolete aliases so no init.el breaks), new create and edit commands, the keymap/transient/picker reshape, and the config migration. One piece isn't a rename: pulling a Linear view back into a local view needs a reverse-compile of IssueFilter, deferred to vNext. Status is Draft with six open questions. No code changed.
* chore: remove the CI workflow until a GitHub repo existsCraig Jennings2026-05-312-62/+0
| | | | GitHub Actions only run on github.com, and cjennings/pearl isn't there, so the workflow had nowhere to run and the README badge 404'd. I removed both. The workflow is preserved in history at 9311cb8 if a mirror repo gets set up later. The compose-window and refresh-view README docs stay; they document real features and don't depend on the repo.
* docs: compose-window knobs and merge-refresh view preservationCraig Jennings2026-05-311-0/+5
| | | | Two shipped behaviors users can see weren't in the README. pearl-compose-window-side and pearl-compose-window-size control where the description/comment editor pops and how big it is. Both now appear in the configuration table. And the refresh section now explains that pearl-refresh-current-view merges by issue id and keeps point and your fold state, re-folding only the issues that changed, so editing then refreshing doesn't collapse what you were working in.
* ci: add GitHub Actions workflowCraig Jennings2026-05-311-0/+61
| | | | There was no CI: the suite, byte-compile, and lint only ran when I ran them locally, so a regression could land on main unnoticed until the next manual run. This adds a workflow that runs make compile, make lint, and make test on every push to main and every PR, across Emacs 27.2 through 30.1 (27.1 is the package floor). An advisory snapshot job surfaces upcoming-Emacs breakage without blocking a merge. The README carries a status badge.
* fix(accounts): pearl-check-setup reports the active account, not the legacy keyCraig Jennings2026-05-312-6/+51
| | | | The setup check gated on pearl-api-key, which is normally unset once pearl-accounts is configured, so under multi-account it wrongly reported "API key is not set" and skipped the connection test even when the active account resolved a key fine. Under accounts mode it now resolves the active account's context, names it, and runs the test. An unusable setup (no default, missing key) reports the reason instead of signaling. Legacy single-account behavior is unchanged. The resolved key is never printed.
* feat(accounts): multi-account support with testsCraig Jennings2026-05-314-46/+843
| | | | | | | | | | | | I added a named-account layer for working more than one Linear workspace from one Emacs. Before this, everything that identified a workspace was a single global: pearl-api-key, pearl-graphql-url, pearl-org-file-path, and the lookup caches. Nothing stopped a work command from running under personal credentials or a work fetch from landing in the personal file, and switching accounts meant re-customizing the key, team, and file by hand and clearing the cache. pearl-accounts maps a name to a per-workspace plist (credential source, org file, default team, optional endpoint), and pearl-switch-account makes one active. Account state flows through an explicit context rather than mutating globals. Every request snapshots its account at dispatch through pearl--graphql-request-async and re-establishes it around the callbacks. A switch mid-fetch can't bleed into a request already in flight: the result finishes into the account it was dispatched under. I centralized this in the one request primitive, so the leak surface is a single function instead of every call site. Rendered files carry a #+LINEAR-ACCOUNT marker, and a buffer guard refuses a command run from one account's file while another is active, naming both, so a work edit can't push under personal credentials. An unmarked legacy file lets reads and refreshes through and acquires its marker on the first refresh. Mutations wait until then. Credentials resolve through auth-source, an env var, or an inline literal, and a resolved key is never persisted through Customize or logged. The active account shows in the mode line. A saved query can carry an :account so it refuses to run under the wrong workspace before any lookup. With pearl-accounts unset, everything behaves exactly as before, off the legacy globals.
* fix(merge): land the type-nil marker fix that should have been in eb56295Craig Jennings2026-05-282-18/+26
| | | | | | | | | | | | The previous commit (eb56295) shipped the buggy pre-fix version of pearl--merge-issues-into-buffer along with a too-weak test. The intended fix and the strengthened test were sitting uncommitted in the working tree when I ran git commit, because I forgot to re-stage pearl.el and tests/test-pearl-merge.el after addressing the critical correctness finding from pre-commit review. The test that landed asserts each touched marker leads to some LINEAR-ID heading, which passes against the buggy code (the markers point at the wrong heading, but it's still a valid heading). This commit is the fix and the real test, which together honor what eb56295's message described. Code change: pearl--merge-issues-into-buffer captures a type-nil copy of the alist marker BEFORE calling pearl--replace-issue-subtree-at-point, instead of pushing the original type-t marker after the replace. The alist marker is insertion-type t (it needs to advance past replaces of EARLIER subtrees so it stays anchored to its own heading), so reusing it after the current subtree's delete-then-insert captures the post-advance position, which is the NEXT subtree's heading. A type-nil copy made before the replace stays anchored at the deletion start and lands on the new heading after the re-insert. Test change: the returns-touched-markers test now resolves each marker to its heading's LINEAR-ID and asserts the set matches {a, b, c} exactly (for 2 updated + 1 added), instead of just checking each marker reaches some heading. With the buggy code the IDs would resolve to {b, c, c} (a's marker advanced to b's heading, b's marker advanced to c's added heading). The strengthened assertion fails on the bug. All 674 ert tests pass. make compile and make lint are clean.
* feat(merge): preserve the user's view across a merge refreshCraig Jennings2026-05-282-9/+151
| | | | | | | | | | | | A merge refresh (the "merge keeping edits" choice on a dirty buffer, or pearl-refresh-current-view on a clean one) used to call pearl--restore-page-visibility at the tail, re-folding the whole buffer to its #+STARTUP overview. That collapsed every subtree the user had expanded, including the comment they were just editing, and disturbed point. The edit-then-merge flow felt jarring. The fix: pearl--merge-issues-into-buffer now returns a marker for every subtree it re-rendered or appended, alongside the existing counts (:touched-markers (M1 M2 ...)). The merge call sites fold just those subtrees via a new pearl--fold-touched-subtrees helper. Kept (locally-edited) and untouched subtrees stay exactly as the user had them, and point is preserved because the localized fold doesn't touch the rest of the page. The full-rebuild paths (Branch A no-buffer, Branch B clean-buffer replace, Branch C-discard) still call pearl--restore-page-visibility. They re-render the whole buffer so the global re-fold is the right behavior. Pre-commit review caught a critical correctness bug in the first draft: the alist marker from pearl--issue-subtree-markers is insertion-type t (it needs to advance past replaces of EARLIER subtrees so it stays anchored to its own heading), and reusing it after pearl--replace-issue-subtree-at-point captured the post-advance position, which is the NEXT subtree's heading, so the fold collapsed the wrong subtree. The fix copies the marker as type-nil BEFORE the replace, so it stays anchored at the deletion start and lands on the new heading after the re-insert. I strengthened the marker test to verify the IDs at the touched markers match the expected set ({a, b, c} for 2 updated + 1 added), not just that they're some valid headings. The original test would have passed against the bug. Five new tests in test-pearl-merge.el cover: merge returns markers with the correct IDs at their headings, skipped (locally-edited) issues don't add to touched-markers, fold-subtree-at-marker hides body content while leaving the heading visible, fold-touched-subtrees is a no-op when pearl-fold-after-update is nil, and fold-subtree-at-marker tolerates a nil marker silently. All 674 ert tests pass. make compile and make lint are clean.
* refactor(render): drop the leading ๐Ÿ’ฌ glyph from the Comments headingCraig Jennings2026-05-285-41/+58
| | | | | | | | | | Pearl was rendering the Comments subtree heading as `*** ๐Ÿ’ฌ Comments [N/M]`. Craig flagged that it looks strange, and the renderer should work cleanly before it gets glyphed. I dropped the emoji prefix so the heading reads `*** Comments [N/M]`. Partly reverts 41a3396. The overlay-glyph idea (a content-aware display overlay on the leading stars) stays tracked as a separate task. I touched pearl--format-comments (the new-subtree literal) and pearl--append-comment-to-issue (both the new-subtree insert and a couple of stale docstring references). The append-locator regex stays tolerant of the legacy `๐Ÿ’ฌ Comments` heading and of the older pre-2026-05 `Comments ๐Ÿ’ฌ N/N` trailing-glyph layout. Buffers rendered in any of the three states still locate their Comments heading on append. I added one regression test (test-pearl-append-comment-locates-legacy-emoji-prefixed-heading) that seeds a `*** ๐Ÿ’ฌ Comments 1/1` heading and asserts append still finds it, bumps the count to 2/2, and doesn't add a second Comments heading. I updated assertions in test-pearl-comments.el, test-pearl-list-comments.el, and test-integration-acceptance.el to expect the no-emoji form. I also updated docstrings on pearl-fetch-comments-in-list, pearl-list-comments-count-cap, pearl--format-comments, pearl--bump-comments-count-marker, and pearl--comment-count-marker. The README's sample buffer block now matches what the renderer emits. All 669 ert tests pass. make compile and make lint are clean.
* refactor(view-sync): pick-scope returns (display . plist), drops rassoc lookupCraig Jennings2026-05-282-66/+162
| | | | | | | | | | pearl-sync-saved-query-to-linear used to rebuild the entire scope-candidates list a second time (full sort + 2N row allocations) just to rassoc the chosen scope plist back to its display string for the success message. The picker had thrown away the display string the user picked, so the caller had no choice but to re-derive it. I changed pearl--sync-saved-query-pick-scope to return (DISPLAY . PLIST) instead of just PLIST. The caller now reads scope-label from (car scope-pair) and scope from (cdr scope-pair). The rassoc is gone, and so is the second pearl--sync-scope-candidates call. To keep the re-sync path label-consistent with the picker (so a "Synced X (Team: Engineering, visible to the team)" message matches what the picker would have shown for the equivalent first-time sync), I extracted pearl--sync-scope-label as the single source of truth for the label format. The picker, the re-sync branch, and any future caller that needs to describe a scope all go through the helper. Personal scope always renders "[ Personal, only I see it ]" regardless of the shared flag (a Personal + shared combination is meaningless on Linear). Team scopes render "[ Team: NAME, visible to the team ]" or "[ Team: NAME, only I see it ]" per the flag. I added five tests covering the new behavior: scope-label for personal (ignores shared) and team (both shared and private), pick-scope returns the team pair, pick-scope returns the personal pair, pick-scope returns nil on cancel. All 668 ert tests pass. make compile and make lint are clean.
* fix(view-sync): refuse sync of a saved query whose filter is emptyCraig Jennings2026-05-282-27/+56
| | | | | | | | | | A saved query with no filter constraints compiled to nil via pearl--build-issue-filter. The sync command then passed (cons "filterData" nil) to customViewCreate, which JSON-encoded as filterData=null. Linear's CustomViewCreateInput.filterData expects a JSON object, so the API rejected with an opaque error message after the user had already clicked through the scope picker. I added an early refusal in pearl-sync-saved-query-to-linear: when filter-data is nil, signal a user-error naming what's wrong before the scope prompt fires. The message tells the user to add a constraint and re-sync rather than leaving them to puzzle out an opaque API rejection. I restructured the let* nesting to put the empty-filter check between the bindings (where filter-data is computed) and the scope prompt (where the user is asked to pick a destination). The scope binding moved out of the outer let* into an inner let so it doesn't evaluate until after the filter-data guard runs. Two tests cover the guard. The first-time-sync test (no :linear-view-id on the entry) asserts neither the scope prompt nor customViewCreate fires. The re-sync test (entry has :linear-view-id) asserts customViewUpdate doesn't fire either. The re-sync test guards against a future refactor accidentally hoisting the check into the create-only branch. All 663 ert tests pass. make compile and make lint are clean.
* refactor(view-sync): pearl--save-query-mark-synced preserves unknown plist keysCraig Jennings2026-05-282-16/+44
| | | | | | | | The prior implementation rebuilt the spec from a fixed key set (:filter, :sort, :order, plus the four :linear-view-* sync keys and the optional :linear-view-url) and silently dropped anything else. No keys outside that set live on a saved-query entry today, but the persistence layer was coupled to the current schema. A future :description, a user-added annotation, or any new pearl field would vanish on every re-sync. I switched to copy-then-plist-put: shallow-copy the existing spec, plist-put the four sync keys on top, and conditionally plist-put :linear-view-url when this call provided one. Arbitrary keys survive untouched. The URL-preservation behavior on a re-sync that omits view-url is unchanged: the prior :linear-view-url is already in the copied spec, so the when-view-url guard skips the put and leaves it in place. I added one regression test that seeds an entry with :description and :user-tag alongside the standard keys and asserts all of them survive a mark-synced call. All 661 ert tests pass. make compile and make lint are clean.
* docs(view-sync): README sync-up section + spec status to DoneCraig Jennings2026-05-282-6/+45
| | | | | | | | | | | | | | This is Phase 6 of docs/saved-query-sync-spec.org, the docs commit that lands the saved-query โ†’ Linear view sync work. I added a new "Publishing a saved query as a Linear view" subsection under Commands โ†’ Sources. It walks through the two publish commands (sync-saved-query-to-linear and publish-current-source) with their full bindings, the scope-and-visibility prompt the user sees on first sync, the Replace/Rename/Cancel collision flow, the four :linear-view-* metadata keys + :linear-view-url that get persisted, the one-way push semantics, the v1 caveat that :sort and :order don't sync up, and the two-stage delete-saved-query prompt for synced entries. I updated the existing Sources example block to include the new [saved โ†’ <scope>] picker labels (team-scoped, Personal, and the stale-team `?` case), with a paragraph explaining how the dispatch routes those through customView(id:) on Linear rather than re-running the local filter. I extended the prefix-keymap quick-reference table with f S (sync), f P (publish), and k q (delete saved query) because they were bound in pearl-mode but missing from the table. The Fetching and refreshing command table gained rows for sync-saved-query, publish-current-source, and delete-saved-query. I flipped docs/saved-query-sync-spec.org Status from Ready to Done with the four implementation-phase commit hashes (60a026b, c8b9ad1, fd94237, 0975e7d). No code changes. 660 ert tests still green. make compile and make lint clean.
* feat(view-sync): pearl-publish-current-source convenience wrapperCraig Jennings2026-05-284-2/+106
| | | | | | | | | | This is Phase 5 of docs/saved-query-sync-spec.org (ยง New: pearl-publish-current-source). When I'm reading a local saved query rendered in pearl and decide it should be a Linear view, I now have a one-chord publish instead of the M-x round-trip through pearl-sync-saved-query-to-linear with the name lookup. The new command reads the active buffer's #+LINEAR-SOURCE header and dispatches to pearl-sync-saved-query-to-linear with the name pre-filled when the source is a :type filter that matches an entry in pearl-saved-queries. Other shapes refuse with a clear message: no source header at all, the source is already a :type view (no publish needed), the type isn't filter, the source has no usable name (transient ad-hoc filter, or whitespace-only name), or the recorded name doesn't match any local saved query. I bound the command under C-; L f P in pearl-fetch-map. The transient gets ("U" "upload current source") in the Fetch group because P is taken in the transient's flat namespace, and the verb-prefix shape revisit task tracks reshaping that. 7 new tests cover each refusal branch and the dispatch path, including a regression for the whitespace-only :name case the pre-commit review surfaced. The prior check used string-empty-p, which let " " fall through to the no-matching-saved-query branch with a visibly-blank name in the error message. string-blank-p routes it to the cleaner no-name branch. The keymap and transient menu tests gained coverage for the new binding. 660 ert tests total green. make compile and make lint clean.
* feat(view-sync): pearl-pick-source distinguishes [saved] / [saved โ†’ scope] โ†ตCraig Jennings2026-05-283-32/+256
| | | | | | | | | | | | | | | | | | | | | | / [view] This is Phase 4 of docs/saved-query-sync-spec.org (ยง Extended pearl-pick-source candidate label). The picker now reads where each saved query lives at a glance: - `[saved] Name`: local-only saved query, dispatches as :type filter (unchanged) - `[saved โ†’ Engineering] Name`: synced to a team-scoped Linear view - `[saved โ†’ Personal] Name`: synced as a personal Linear view - `[saved โ†’ ?] Name`: synced, but the team id no longer resolves (deleted/renamed on Linear) or the teams cache wasn't loaded - `[view] Name`: Linear-authored view favorite (unchanged) A synced entry's source plist gets :type view, so the existing view-dispatch branch in pearl-pick-source routes through pearl--query-view-async against the linked Linear view. Refresh and pagination honor whatever the view's filter is on Linear now, not the local authoring filter pearl built it from. I added pearl--saved-query-scope-label to resolve the scope string from (spec, teams) and extended pearl--pick-source-candidates with an optional teams argument. pearl-pick-source fetches pearl--all-teams lazily, only when at least one saved query carries :linear-view-id, and feeds the result in. An all-local configuration pays no extra network cost. While in the picker code I also closed a latent gap the review surfaced: pearl-open-current-view-in-linear was about to break on synced-saved-query sources because the source plist had no :url. I extended pearl--customview-create-async and -update-async to fetch `url` in the response, pearl--save-query-mark-synced to take an optional VIEW-URL arg and store it as :linear-view-url, and the picker source plist to surface :url when the entry has one. Entries synced before this commit have no stored URL. A re-sync repopulates it. Mark-synced preserves a prior URL when VIEW-URL is omitted, so a re-sync whose API response somehow lacks the URL doesn't erase the working one. 15 new tests across test-pearl-favorites.el (the picker labels for team-scope, Personal, unknown-team, no-teams-arg, URL surfaced + URL absent, mixed local/synced) and test-pearl-saved-query-sync.el (mark-synced stores URL when given, preserves prior URL on re-sync omission). The existing 4 pick-source tests stay green. They pass either no teams arg or nil, so the scope-resolution branch is dead in their cases. 653 ert tests total green. make compile and make lint clean. Lower-severity findings from the review filed as follow-ups: the `?` label conflates "unknown team" and "no teams data" (could be split). pearl--all-teams cache has no TTL so stale team names persist across renames. The lazy teams fetch reuses the "Fetching favorites..." progress message during its own roundtrip. Synced entries silently drop the local :sort/:order because Linear's view doesn't carry them in v1.
* feat(view-sync): extend pearl-delete-saved-query with delete-on-Linear promptCraig Jennings2026-05-282-13/+221
| | | | | | | | | | Phase 3 of docs/saved-query-sync-spec.org. pearl-delete-saved-query on a synced entry (one carrying :linear-view-id) now asks a second question after the local-delete confirmation: also delete the linked Linear view? Yes calls customViewDelete, no unlinks only and leaves the Linear view in place. Local-only entries take the unchanged single-prompt path. If customViewDelete fails on the API side, a fallback yes-or-no prompt asks whether to drop the local entry anyway. Accepting orphans the Linear view, and the success message names the view id explicitly so the user can clean it up by hand. The same view-id-in-message pattern fires on the timeout branch, since a timeout doesn't tell us whether the delete actually completed server-side. I added pearl--customview-delete-async (the mutation, parallel to the create and update helpers from Phase 2) and pearl--delete-saved-query-local (factors the cl-remove + customize-save-variable that both the local-only and the synced-unlink branches need). The new pearl--delete-saved-query-do-linear-delete carries the API path so the top-level command stays readable. I added 7 tests in test-pearl-saved-query-sync.el covering the customViewDelete success and failure parses, the synced yes/yes path (API call fires + local removed), the synced yes/no path (unlink only, no API call), the API-failure-then-delete-anyway path (orphan message names the view id), the API-failure-then-keep path (asserts the API call fired so a refactor can't silently route through the unlink branch), and the local-only entry unchanged path. The existing pearl-delete-saved-query tests in test-pearl-adhoc.el stay green. Their fixtures don't carry :linear-view-id, so they take the local-only branch. 638 tests total green. make compile and make lint clean.
* feat(view-sync): pearl-sync-saved-query-to-linear + transient entryCraig Jennings2026-05-284-62/+842
| | | | | | | | | | | | | | | | | | | | | pearl-sync-saved-query-to-linear promotes a local pearl-saved-queries entry to a Linear Custom View via customViewCreate, or updates an already-synced entry in place via customViewUpdate. This is Phase 2 of docs/saved-query-sync-spec.org. Re-syncing keeps the stored scope. First-time sync prompts for the destination (team + visibility), with each candidate spelling out the complete end-state. Same-name collisions in the chosen scope prompt Replace / Rename / Cancel. A successful sync extends the entry with :linear-view-id, :linear-view-team-id, :linear-view-shared, :linear-view-synced-at. If the customize-save-variable persist fails after a successful API call, the message names the orphan view's id explicitly so the user can re-sync with Replace to reconcile. I bound the command under C-; L f S in pearl-fetch-map and added ("S" "sync saved query") to the transient's Fetch group. filterData is the existing pearl--build-issue-filter output verbatim. The 2026-05-28 live probe confirmed the shape passes through customView.filterData with no translation needed. Four related fixes ride along: - Extended pearl-get-teams-async to fetch `key' alongside `id name'. It shares pearl--cache-teams with pearl--all-teams, so an async-first populate would otherwise leave the cache without `key' and silently break filter-team lookups. - Normalized the stored :linear-view-shared flag to a strict boolean at the re-sync read site. `:json-false' is truthy in Elisp, so an unnormalized stored false would flip the view to team-shared on re-sync without asking. - pearl--sync-saved-query-await now returns `(:success nil :timeout t)' on a wait-for elapse, so the caller can distinguish a timeout from a Linear-side rejection and tell the user the request may still complete server-side. - The success path now emits a single message via pearl--sync-record-or-orphan-error. The prior code clobbered the orphan-recoverable view-id with a second message. I hoisted the prompt-sentinel block (pearl--filter-cancel etc.) above its first user. The prior order produced a forward-reference compile error the moment any other change touched the file. I added 31 tests in test-pearl-saved-query-sync.el covering the scope candidates, the customViewCreate/Update parsing, the entry persistence (including the :json-false overwrite case), the orphan-id error path, the timeout sentinel, and the get-teams-async cache parity. The keymap and menu tests gained coverage for the new binding. 631 ert tests total green. make compile and make lint clean. Lower-severity findings filed as follow-ups: stale pearl--cache-views across a schema upgrade, empty filter encoding to null filterData, the pearl--save-query-mark-synced rebuild dropping unknown plist keys, and the scope-label rassoc rebuilding the candidate list to look up a string the picker already had.
* docs(view-sync): phase 1 verified, pearl filter output works as filterDataCraig Jennings2026-05-281-0/+15
| | | | | | Ran a live round-trip against api.linear.app to close phase 1 of `docs/saved-query-sync-spec.org`. Pearl's existing `pearl--build-issue-filter` produces a structure that `customViewCreate` accepts as `filterData` verbatim, the view created with the expected shape, `customView(id:)` read back the identical structure, and `customViewDelete` cleaned up. No new mapping helper required for phase 2; the compiler is the mapping. Recorded the probe results in the spec's Review dispositions section. Phase 1's todo entry can move to DONE; phase 2 (the user-facing `pearl-sync-saved-query-to-linear` command) can consume the existing compiler output directly.
* docs(spec): saved-query-sync to Linear views, Ready after sprint reviewCraig Jennings2026-05-281-0/+294
| | | | | | | | | | New spec at `docs/saved-query-sync-spec.org` for promoting local `pearl-saved-queries` entries to Linear custom views (`customViewCreate` / `Update` / `Delete`). Written end to end on 2026-05-28 and walked through a six-question sprint review the same day. Status moves Draft to Ready. The live-API probe drove two substantive simplifications. `CustomViewCreateInput.filterData` is type `IssueFilter`, the exact type pearl's existing `pearl--build-issue-filter` already produces, so the proposed filter-mapping helper collapses to a pass-through and implementation phase 1 becomes a sanity-test commit rather than new code. Linear's API has no `customViewArchive` (only Delete), so the delete-or-unlink prompt on the extended `pearl-delete-saved-query` is the actual choice rather than archive-or-unlink. No sort-order input on the view mutations either, so syncing pearl's `:sort`/`:order` moves to vNext. The review itself surfaced one further design move beyond resolving questions: collapsing the two prompts the spec originally proposed (team scope plus shared flag) into a single enriched prompt where each candidate is a complete end-state (`[ Team: Engineering, visible to the team ]` etc.). The same enrichment pattern then applies to `pearl-pick-source`'s label for synced entries (`[saved โ†’ Engineering] Name`). Two pattern-catalog candidates noted for the running rulesets discussion. Implementation decomposes into seven independently-shippable phases, enumerated in the spec's `Implementation tasks (drop-in for todo.org)` section per the spec-review workflow update sent to rulesets earlier in the session. Tasks land in `todo.org`'s Pearl Open Work alongside this commit.
* feat(prompts): split sentinel + default-yes for safe yes/noCraig Jennings2026-05-283-79/+158
| | | | | | | | | | Two UX refinements on the just-shipped prompt surface, both following the principle "the prompt should describe what it's doing and the default should be the most-common choice." First, the sentinel that was uniformly "[ None. ]" really meant two different things at two kinds of prompt, and the label only matched one of them. Filter-dimension prompts (team, state, project, labels, assignee) treat picking the sentinel as "no constraint on this dimension." Every value matches, which is *any*, not *none*. The saved-query prompts (delete, run) treat picking the sentinel as "don't act." That one is *cancel*, not *none* or *any*. Renamed accordingly: `pearl--filter-any` ("[ Any. ]") for the five filter dimensions and `pearl--filter-cancel` ("[ Cancel. ]") for the two saved-query prompts. The generic helper became `pearl--with-sentinel SENTINEL CANDIDATES` so each call site picks the label that fits its case. The predicate became `pearl--filter-sentinel-value-p` (recognizes either sentinel or empty/nil) so the cancellation logic is unchanged. Second, three non-destructive yes/no prompts ("Open issues only?", "Save this filter locally...", "Save N fields across M issues?") moved from `y-or-n-p` to a new `pearl--read-yes-no` helper. The helper renders a completing-read over ("yes" "no") with the most-common choice as the default and topmost candidate, so RET takes it without typing. Default is "yes" for all three (each is a do-the-thing-I-asked confirmation), but the helper takes a DEFAULT arg so a future prompt where "no" is more common can opt in. The destructive prompts (delete issue, delete saved query, delete comment) stay as `yes-or-no-p`. Typing "yes" there is a deliberate safety affordance, not friction worth removing. Tests cover the sentinel-value predicate across both sentinels + empty/nil + real values, the `pearl--with-sentinel` helper, the `pearl--read-yes-no` t/nil return and default-ordering behavior, and the three save-test stubs swapped to mock the new helper.
* docs(README): explain that pearl follows browse-url-browser-functionCraig Jennings2026-05-281-0/+15
| | | | A new "Which browser opens for Linear URLs" subsection under Configuration names the fact that pearl hands every URL to `browse-url' and never picks a browser of its own, so the answer to "why this browser?" is always the Emacs setting. Lists the common values for `browse-url-browser-function' (default-browser, xdg-open, firefox / chrome, generic) and points at `browse-url-handlers' for per-URL routing.
* feat(saved): add pearl-delete-saved-query and a sentinel cancel for runCraig Jennings2026-05-283-4/+102
| | | | | | `pearl-saved-queries' was write-only from a user's perspective. Save was bound to the builder, run was bound to a key, but removing a stale entry meant `M-x customize-variable RET pearl-saved-queries RET` and a manual delete from the Customize buffer. `pearl-delete-saved-query` under `C-; L k q` (and `q` in the transient's Delete group) closes that gap: completes over the saved query names with `[ None. ]` at the top as a cancel, confirms, removes the entry, and persists via `customize-save-variable`. Linear is untouched. Saved queries are local. Pushing them up to Linear as custom views is a separate spec. While in the same path, `pearl-run-saved-query` got the same sentinel cancel. Both prompts use the keep-order completion table so `[ None. ]` stays at the top regardless of the user's completion sorter. Both error cleanly when `pearl-saved-queries` is empty rather than offering an empty picker. Six tests cover the new command (delete-on-yes, no-at-confirm, sentinel-cancels, unknown-name-errors, empty-list-errors) plus run's sentinel cancel.
* fix(filter): pin builder candidate order so [ None. ] stays at the topCraig Jennings2026-05-282-10/+44
| | | | | | Wrapping each builder prompt with `pearl--with-none` put the sentinel first in the candidate list, but the completion framework's default sort (vertico-sort-history-length-alpha, prescient, ivy's sorter, etc.) re-sorted alphabetically and any real candidate starting with a letter before "[" pushed the sentinel down. Linear's "Software Engineering" outsorted "[ None. ]", so the new pattern failed at the very first prompt. The standard Emacs hook for "I sorted these on purpose" is a completion table that returns `(metadata (display-sort-function . identity))` for the `metadata` action. The new `pearl--completion-table-keep-order` helper does exactly that, and the five builder prompts (team, state, project, labels, assignee) now wrap their candidate lists through it. The framework keeps its default sort everywhere else. Only these five prompts opt out, in the place where author-order is meaningful.
* feat(filter): show "[ None. ]" as the topmost option in builder promptsCraig Jennings2026-05-282-22/+83
| | | | | | | | The builder's state / project / team / labels / assignee prompts each relied on the "empty input means no constraint" convention, with a parenthetical hint in the prompt label ("Team (empty for any):"). The convention is invisible, has to be remembered, and reads as a non-choice rather than a choice. Now every prompt carries `pearl--filter-none` ("[ None. ]") as the first candidate and uses `require-match` so the user picks from the list. Selecting the sentinel is the same logical opt-out as empty input was, but the choice is on screen rather than in the user's head. Two small helpers (`pearl--with-none`, `pearl--filter-none-value-p`) and one defconst keep the sentinel string in one place. The prompt labels lost their parenthetical hints since the affordance is now in the candidate list. Five unit tests cover the sentinel predicate and the list helper across the normal / boundary / "real value" cases. The pattern generalizes well past the builder: any time pearl prompts the user to pick between a value and "no value," the no-value option belongs in the list, not hidden behind empty input.
* refactor(preamble): tighten buffer affordance comments to keymap formCraig Jennings2026-05-272-7/+8
| | | | The four-line preamble enumerated `M-x pearl-save-issue` / `M-x pearl-create-comment` / etc., even though pearl-mode binds the whole verb-prefix keymap under `C-; L` and that's what users actually type after the first session. Replaced with two lines: one for inline edits + save, one for the verb-prefix groups (edit, refresh, menu, pick-source). Reads as a key reference rather than a command list. The build-org-content test now matches the affordance line on "save" instead of the literal `pearl-save-issue` so the next rephrase doesn't trip it.
* feat(sources): favorites, views, and filters via pick-sourceCraig Jennings2026-05-278-39/+597
| | | | | | | | | | Pearl now treats Linear sources (favorites, custom views, ad-hoc filters, saved queries) as one surface. `pearl-pick-source` is the unified picker: it lists every Linear favorite alongside every locally saved query, and dispatches by kind. View / project / cycle / label / user favorites resolve to runnable filter or view sources; issue / document / dashboard favorites open in the browser since they aren't lists. The filter compiler grows id-based forms (`:label-id` and `:assignee-id`), so favorites that name a Linear entity (a user, a label) compile to rename-proof queries rather than fragile name matching. The ad-hoc builder gets a matching member-assignee option ("me / member / any"), so a one-off "what's a teammate working on?" doesn't require building a saved query first. Two pre-existing builder bugs surfaced during verify and got fixed in the same pass. The builder previously passed display names straight to the compiler, but `:team` compiles to `team.key.eq` and `:project` to `project.id.eq`, so Linear rejected every builder run that pinned a team or project. Now the builder resolves the picked name to its key / id before assembling. And `pearl-list-issues-by-project` previously injected `:assignee :me` into the filter, hiding every teammate's work in the project. It now returns the whole project. The save prompt on the builder was rewritten so the local-only nature is unambiguous, and the README grew a Sources section plus a keymap-table entry for `f s`.
* docs: add the issue-sources spec (favorites, views, filters)Craig Jennings2026-05-271-0/+143
| | | | I drafted a design for unifying Pearl's fetch surface around sources: read the viewer's Linear favorites and run them by type-dispatch behind one pearl-pick-source picker, with the per-dimension filters as the primitives favorites resolve into and the ad-hoc builder as the exploratory fallback. It fixes the by-project me-lock and resolves label and user favorites by stable id, which needs two small filter-compiler forms (:label-id, :assignee-id). The Linear favorites schema is verified against the live API and Codex's review is incorporated, so the spec is Ready.
* refactor: verb-align command names and prefixes, use issue not ticketCraig Jennings2026-05-2712-100/+105
| | | | | | The keybinding prefixes now each name an action verb: f fetch, e edit, c create, k delete, o open, y copy. The url group is gone, its actions split into open and copy, and the direct lowercase c (new comment) is dropped so c can head the create group. Two commands carried non-verb names, so I renamed them: pearl-new-issue is now pearl-create-issue and pearl-add-comment is now pearl-create-comment. The old sync helper that already held the pearl-create-issue name moved to the private pearl--create-issue, where it belongs as internal plumbing. I also swept "ticket" to "issue" across the labels, transient, docstrings, and README. Linear's own term is "issue" and the buffer mirrors Linear, so the command names already said "issue" while the labels said "ticket". Now they agree. The transient's per-entry keys still trail the keymap verbs, which stays its own task.
* feat(compose): open compose and conflict buffers in a configurable side windowCraig Jennings2026-05-272-2/+53
| | | | The compose buffers (edit-description, add-comment) and the smerge conflict buffer popped wherever display-buffer happened to put them. Now they open in a side window, defaulting to the bottom at 30% of the frame, with two defcustoms to control it: pearl-compose-window-side (bottom / top / left / right) and pearl-compose-window-size (a fraction of the frame, or an absolute line/column count). pearl--compose-display-action builds the display-buffer-in-side-window action and both pop sites pass it through pop-to-buffer, so Pearl always applies the side window over any display-buffer-alist entry for these buffers.
* fix(save): detect comment edits with a rendered-Org hash, not the md round-tripCraig Jennings2026-05-272-27/+87
| | | | | | A comment whose markdown doesn't survive md->org->md (a heading, single-asterisk italics) was flagged changed on every save, because the dirty scan compared org-to-md of the rendered body against the stored markdown hash. When the comment was authored by someone else, that surfaced as "1 read-only comment skipped" on every save even though nothing had been edited. Descriptions already dodge this: 9424b84 gave them LINEAR-DESC-ORG-SHA256 and compared Org-to-Org. I did the same for comments. pearl--format-comment now writes LINEAR-COMMENT-ORG-SHA256 (the rendered body hash), and a new pearl--comment-dirty-p compares the current body against it, falling back to the old markdown hash for comments rendered before the baseline existed. Both detection sites use it: the save scan and the edit-current-comment no-op check. The markdown hash stays for the remote-conflict gate.
* feat(labels): render Linear labels as Org tags on issue headingsCraig Jennings2026-05-274-5/+238
| | | | | | | | An issue's Linear labels now appear as Org tags on its heading (** TODO [#B] Title :bug:backend:), so the org-native gestures work: filter by tag, build a tag agenda, sparse-tree on :bug:. Before this, labels lived only in the :LINEAR-LABELS: drawer, which Org's tag machinery can't see. pearl--label-name-to-tag slugifies a label name to a tag: downcase, non-[[:alnum:]_] runs become a single underscore, Unicode letters preserved. pearl--label-tags builds the deduplicated set, first occurrence winning on a collision. The renderer appends them, and pearl-edit-labels rewrites them through pearl--set-heading-label-tags after updating the drawer. This is render-only. The :LINEAR-LABELS: drawer stays the source of truth, hand-edited heading tags are ignored by save and the dirty scan and get rewritten from Linear on the next change or fetch, and the way to change labels is still pearl-edit-labels. Bidirectional tag editing (heading tags back to Linear) is out of scope.
* feat(commands): add pearl-copy-issue-url, bound at u cCraig Jennings2026-05-273-6/+44
| | | | I added pearl-copy-issue-url. It copies the Linear URL of the issue at point to the kill ring and the system clipboard, reading the stored LINEAR-URL drawer property so there's no network call, and works from anywhere inside the issue subtree. kill-new carries it to the clipboard through interprogram-cut-function, so nothing shells out to a clipboard tool. Bound at u c in the url group, beside u o (open in browser).
* docs: document pearl-mode and the completed keymapCraig Jennings2026-05-271-7/+26
| | | | I replaced the old save/edit/new/delete prefix writeup with pearl-mode, which auto-enables in Linear buffers, and the full scheme: the hot keys directly under the prefix plus the fetch, edit, new, delete, and url groups.
* feat(keys): add pearl-mode and complete the command keymapCraig Jennings2026-05-272-63/+224
| | | | | | | | Pearl is now drivable entirely from the keyboard. pearl-mode turns on automatically in any buffer Pearl renders, detected by the #+LINEAR-SOURCE header on org-mode-hook, and binds the command map under pearl-keymap-prefix (default "C-; L"), so the keys are live without any global setup. I filled out pearl-prefix-map so every day-to-day command is reachable. The hot-path commands bind directly under the prefix (list, refresh view, refresh issue, save, edit description, new comment). The rest sit in category sub-maps (fetch, edit, new, delete, url). The common ones appear in both places, so edit-description is the direct d and also e d. Delete moved from d to k so d is the direct edit key, and the old save sub-map is gone now that save is the direct s and S. The org-mode-hook is registered at load rather than through an autoload cookie. An autoloaded hook would fire before pearl loaded, calling a void function, and would pull the whole package into every org buffer the user opens. Freshly fetched buffers also enable the mode directly in the writer.
* feat(render): render issue titles and the view name verbatimCraig Jennings2026-05-276-18/+49
| | | | | | The buffer should mirror Linear: a value on the page appears exactly as Linear stores it, so the heading title matches what you'd see opening the issue in Linear itself. I flipped pearl-title-case-headings to default nil so titles render verbatim. I also stopped running the view name in the #+title through the title-caser, which was coupled to the same flag. The smart-title-case path stays as opt-in tidying for anyone who wants it. Two transformations remain, both forced by Org's syntax rather than chosen: state names become TODO keywords (Org keywords can't contain spaces, so the real name stays in the LINEAR-STATE-NAME drawer), and descriptions and comments convert between Markdown and Org. Documented under "Fidelity to Linear" in the README.