aboutsummaryrefslogtreecommitdiff
Commit message (Collapse)AuthorAgeFilesLines
* 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.
* fix(commands): run add-comment and delete from anywhere in a ticketCraig Jennings2026-05-263-13/+30
| | | | | | pearl-add-comment and pearl-delete-current-issue guarded position with the nearest-heading check, so from inside a comment subtree, or any heading without a LINEAR-ID, they errored "Not on a Linear issue heading". Both docstrings already claimed they work from anywhere in the subtree, so the behavior was just wrong. The edit and save commands already climb to the issue via pearl--goto-issue-heading-or-error. I switched these two to the same guard, so every ticket-scoped command resolves the enclosing issue from anywhere in its subtree, including from inside a comment. The redundant LINEAR-ID re-checks are gone since the guard guarantees it. Walk-up tests added for both.
* feat(refresh): re-tidy drawers via org-tidy when it's activeCraig Jennings2026-05-262-3/+37
| | | | | | After a refresh, merge, or comment add, pearl folds property drawers so the page stays scannable. When org-tidy-mode is on, org-tidy owns drawer display with its own compact inline symbol, so a plain native fold doesn't match. Worse, org-tidy's overlays go stale when pearl rewrites the buffer, leaving the new drawers untidied. I added pearl--hide-or-tidy-drawers, which re-tidies via org-tidy-buffer when org-tidy-mode is active and falls back to the native fold otherwise. I routed the three drawer-hide paths (full render, single-issue refresh, comment add) through it. org-tidy stays an optional soft dependency, guarded by bound-and-true-p and fboundp, so a setup without it behaves exactly as before.
* feat(comments): fold the drawer and update the heading count on addCraig Jennings2026-05-263-6/+46
| | | | Adding a comment left three rough edges. Its property drawer rendered expanded while every other drawer in the page was folded, so I fold the new subtree's drawers right after inserting it. The Comments heading's count went stale on add: a freshly created subtree now shows πŸ’¬ Comments 1/1, and an existing πŸ’¬ Comments N/M bumps to N+1/M+1. And the fallback that creates the Comments subtree when an issue had none was still inserting a glyph-less "Comments". It now leads with the πŸ’¬ like the rest.
* feat(refresh): prompt instead of silently deferring a dirty refreshCraig Jennings2026-05-263-14/+113
| | | | Running pearl-list-issues on a buffer with unsaved edits used to print a passive "refresh deferred" message and do nothing visible, so it read as a no-op. Now it prompts: (d)iscard and rebuild, (m)erge by LINEAR-ID keeping the edits, or (q) cancel. Branch C of pearl--update-org-from-issues dispatches on the choice: discard rebuilds in place, merge hands off to the same by-id merge that pearl-refresh-current-view uses, and cancel leaves the buffer untouched.
* fix(refresh): merge keeps any locally-edited ticketCraig Jennings2026-05-262-4/+30
| | | | The merge refresh protected a subtree from being overwritten only when its description body had changed, since pearl--subtree-dirty-p is description-only. A local edit to the title, state, priority, assignee, labels, or a comment looked untouched and got re-rendered away. I added pearl--subtree-has-local-edits-p, which wraps the comprehensive per-field detector the save path already uses, and pointed both merge protection sites at it. A merge now keeps any ticket you've touched in any field. The same gain flows to pearl-refresh-current-view.
* fix(comments): don't recolor the leading heading starsCraig Jennings2026-05-262-11/+27
| | | | | | The comment overlay started at the line beginning, so it sat on top of the leading stars. With a package like org-superstar that dims those stars (and composes a bullet from them) via a face, our overlay's face overrode it and the dimmed stars reappeared in our color, a stray "***" before the bullet. The darker, body-spanning read-only overlay made it obvious, but the overlay had covered the stars since comment highlighting first landed. I start the overlay just past the leading stars now (the point right after the `^\*+ ` match), so the stars and the bullet keep org's own fontification and only the heading text and body get our face. The highlight tests check the face on the heading text and assert the stars are left uncovered.
* feat(comments): dim read-only comments so they read as disabledCraig Jennings2026-05-262-9/+43
| | | | A comment you can't edit only had a grey heading, and the grey (`shadow`) was light enough to blend with the body text of your own comments. Now the read-only overlay covers the whole comment subtree, heading and body, so the comment recedes as a unit. And `pearl-readonly-comment` uses a darker background-aware grey (gray40 on a dark theme, gray60 on light, `shadow` as the fallback). Editable comments keep the heading-only overlay so your own comment text stays normal-colored, with the green heading as the "this one's yours" cue.
* refactor: remove the dead state-to-todo mapping subsystemCraig Jennings2026-05-2616-145/+32
| | | | | | `pearl-state-to-todo-mapping` and its derived regex stopped doing real work once keyword rendering moved to slugifying the Linear state name (`pearl--state-name-to-keyword`) and the keyword-to-state direction moved to resolving through the team's workflow states. The defcustom fed only `pearl--get-todo-states-pattern`, which had no caller left, and the two cache vars existed only to serve that dead function. State extraction reads `org-get-todo-state` off the buffer's `#+TODO` line, so nothing live touched the mapping. I removed the defcustom, both cache vars, and the dead function, and dropped the three pattern tests that exercised it. Removing the defcustom orphaned the mapping bindings that ~12 test setup macros still carried. They were passing through slugification rather than the map, so I stripped them too and kept the `org-todo-keywords` bindings the temp buffers actually need. Also dropped the stale README config row.
* feat(comments): lead the Comments heading with the πŸ’¬ glyphCraig Jennings2026-05-264-19/+22
| | | | | | The Comments heading rendered the count marker as `Comments πŸ’¬ 5/18`, with the glyph wedged between the word and the count. I moved the πŸ’¬ to the front of the heading, rendering `πŸ’¬ Comments 5/18`, so it reads as an icon for the section rather than part of the number. `pearl--comment-count-marker` now returns just ` 5/18`, and `pearl--format-comments` leads with the glyph. The append locator that finds an issue's Comments subtree now tolerates the leading πŸ’¬, and still matches the old `Comments πŸ’¬ N/N` layout so a buffer rendered before this change keeps working on the next add-comment.
* fix(comments): apply comment highlighting on the bulk render pathCraig Jennings2026-05-262-2/+43
| | | | | | Comment headings only got their green (editable) / grey (read-only) coloring after `pearl-refresh-current-issue`, an add-comment, or a view merge (the three paths that call `pearl-highlight-comments`). A plain `pearl-list-issues` rendered through `pearl--update-org-from-issues`, which never called it, so a fresh fetch showed no coloring until I refreshed. I added the highlight call to branches A and B of `pearl--update-org-from-issues`, right after the content lands in the buffer. Branch C defers a dirty buffer without writing, so it stays uncolored. The regression test renders the comment fixture and asserts both headings carry the editability overlay (own to editable, other's to read-only) straight from the bulk render.
* feat(coverage): print a terminal coverage summary from the SimpleCov reportCraig Jennings2026-05-264-2/+378
| | | | | | `make coverage` only reported the JSON's file size, so reading the real number meant opening the report by hand. I added a self-contained `scripts/coverage-summary.el` that parses the SimpleCov JSON and prints per-file covered/total lines with a percent, a line-weighted project figure, and a source-weighted figure. A tracked source missing from the report counts as 0% rather than dropping out silently, so a source that never got instrumented still shows up. A new `coverage-summary` target runs it against the last report, and `make coverage` chains it onto the tail of a local run (CI emits coveralls.json, not simplecov.json, so it's skipped there). Covered by `tests/test-pearl-coverage-summary.el`: parser hit/executable/merge-key semantics, the per-file records, and the error paths for a missing or malformed report.
* chore: gitignore the local inbox/ directoryCraig Jennings2026-05-261-0/+1
| | | | inbox/ holds cross-agent message drops and wrap-up lint follow-ups β€” local workflow state, the same class as .ai/ and todo.org. Keep it out of the tracked tree.
* feat: resolve a cycled TODO keyword to its Linear state at saveCraig Jennings2026-05-265-80/+209
| | | | | | Cycling a ticket's TODO keyword (C-c C-t) now counts as a state edit. The dirty scan gains a keyword-cycle arm: state is dirty when the explicit id moved off its baseline (the picker arm, as before) or when the keyword diverged from the synced state name's slug. At save, the picker arm pushes the explicit id; the keyword-cycle arm resolves the keyword to a team state id by slug match, first by position, and pushes that. A keyword no team state matches is reported skipped rather than guessed. set-heading-state now slugifies the keyword instead of the static-map lookup, so the picker writes the same keyword the renderer derives. Without that, a picker edit to a non-standard state would read as permanently cycled. That retires pearl--map-linear-state-to-org, whose only caller this was, and its tests.
* docs: supersede the keyword spec's sync-back half with save-model-v2Craig Jennings2026-05-261-1/+5
| | | | The spec predates save-model-v2, which removed the org-sync push path its "sync-back: cycled keyword β†’ Linear state" section relied on. Mark that section obsolete: state now reconciles at save through pearl--save-state-field, and the cycled-keyword β†’ state-id resolution is the c8 task. Record the invariant c8 leans on β€” every fresh render sets the keyword to slugify(state-name) β€” and the one stale-render caveat a refresh closes.
* feat(render): rebuild the derived #+TODO header on merge refreshCraig Jennings2026-05-253-5/+69
| | | | | | A same-source refresh updates issue subtrees in place and keeps retained dirty subtrees the merge skips, so the #+TODO line has to stay honest across it -- a refresh can surface a new state, and a kept heading's keyword must still be declared. pearl--update-derived-todo-header scans the final displayed buffer (every LINEAR-ID heading's TODO keyword + :LINEAR-STATE-TYPE: drawer), unions that with the fetched teams' states, and rewrites the line via pearl--derive-todo-line. Scanning the final buffer rather than the fetched issue list is what covers the retained subtrees. A legacy heading with no state-type drawer is classified by org-done-keywords. pearl--merge-query-result calls it after the merge, beside the source-header update. The merge and integration test setups stub pearl--team-states so the gather stays network-free.
* feat(render): derive the #+TODO line and keywords from team workflow statesCraig Jennings2026-05-253-10/+94
| | | | | | | | The renderer wrote a fixed #+TODO line and mapped state names through a static defcustom, so a workspace's real states (Dev Review, PM Acceptance, ...) never showed up. Now the displayed issues' teams drive both. pearl--gather-header-states collects each team's full state set (cached via pearl--team-states, teams in first-seen order, states by position) plus every displayed issue's own state, so a failed team fetch still leaves its issues' states declared. pearl--build-org-content takes that list and derives the #+TODO via pearl--derive-todo-line. pearl--format-issue-as-org-entry renders each keyword with pearl--state-name-to-keyword instead of the static map -- slugify reproduces the old defaults, so standard states are unchanged -- and writes a :LINEAR-STATE-TYPE: drawer so a later merge scan can classify a retained heading. The team-states query gains type and position. The integration test stubs pearl--team-states so the render stays network-free. The merge-refresh header rebuild is the next commit. Until then a same-source refresh keeps the previous header.
* feat(render): add the pure helpers for workspace-derived TODO keywordsCraig Jennings2026-05-252-0/+122
| | | | | | Two pure functions behind the coming workspace-faithful #+TODO line. pearl--state-name-to-keyword slugifies a Linear state name to an Org keyword: upcased, non-alphanumeric runs collapsed to a single hyphen, edges trimmed, Unicode letters kept (so "Γ…ngstrΓΆm" survives), and an all-symbol name falls back to TODO. pearl--derive-todo-line turns an ordered state list into the "ACTIVE... | DONE..." string: completed/canceled/duplicate types go after the bar, the rest before, order preserved within each side, de-duplicated by slug, with the hardcoded default returned for an empty list. There's no caller yet -- the render integration and the gather pipeline land next. This is the derivation half of the keyword spec. The sync-back half it described is superseded by save-model-v2 (state reconciles at save) and the separate keyword-cycle task.
* fix(save): extend the killed-buffer guard to the text-field saversCraig Jennings2026-05-252-14/+46
| | | | The atomic savers got this guard last commit, and the free-text savers share the same async exposure. pearl--save-field-push advances the provenance hash and runs the :after-push thunk through the marker after the push returns, and the description saver's outcome callback resyncs the Org hash the same way -- both signal if the buffer was killed in between. Both now check pearl--marker-live-p and skip the buffer touch while still reporting the remote outcome. I moved pearl--marker-live-p above the first caller so it's defined before all three use sites (no forward reference).
* fix(save): guard atomic-saver commit callbacks against a killed bufferCraig Jennings2026-05-252-3/+49
| | | | | | The per-field atomic savers advance the baseline and rewrite the display through the marker (org-entry-put marker / org-with-point-at marker) inside the async push callback. If the buffer is killed before the push lands, those touch a dead marker and signal. The deleted pearl--push-issue-field carried this guard, but it went away with the immediate-push setters and the atomic engine never picked it up. pearl--run-atomic-field-save now skips the commit when the marker's buffer is dead (a new pearl--marker-live-p, which also passes a nil marker for synthetic callers) and still reports the remote outcome -- the push already landed, so only the local buffer update is moot. All three commit sites are guarded: the clean push, the converged-remote case, and the use-remote conflict resolution.
* docs: drop the stale sync-hook mention in set-heading-stateCraig Jennings2026-05-251-3/+3
|
* docs: rewrite the README editing section for the unified save modelCraig Jennings2026-05-251-29/+35
| | | | | | The README still described the old per-field push commands and the org-sync hook mode, both removed in save-model-v2. I rewrote the editing section around the one write path: you edit in the buffer, save the ticket, and Pearl reconciles each changed field against the remote. It now leads with pearl-save-issue / pearl-save-all, spells out how each field is edited (the cookie for priority, a picker for state/assignee/labels, hand-editing for text), explains that picking a constrained field marks it changed rather than pushing, and describes the conflict gate for both free-text and atomic fields. The old "Org TODO sync" section drops the removed enable/disable hooks and keeps the state-to-keyword mapping as render config, with pearl-edit-state as the way to change state. The intro, the feature list, and the use-package and prefix-key examples lose their stale references. I kept the keyword-cycle-to-state and keyword-derivation work named as planned follow-ups rather than claiming they work, since they're not in yet.
* refactor(save): remove the immediate-state-push org-sync machineryCraig Jennings2026-05-255-346/+39
| | | | | | | | The opt-in org-sync mode pushed Linear state immediately β€” on every TODO-keyword cycle (the org-after-todo-state-change hook) and on every buffer save (the after-save hook scanning the whole file). That's the last immediate-push path, and it contradicts the one-write-path model the rest of v2 builds, so I removed it: pearl-enable-org-sync / -disable-org-sync, pearl-org-hook-function, pearl-sync-org-to-linear, pearl-sync-current-heading-to-linear, and the now-orphaned pearl--process-heading-at-point, pearl--map-org-state-to-linear, and pearl--update-issue-state-async. This finishes the "make org-sync private" task β€” there was nothing left to make private. Cycling a TODO keyword now marks state dirty (reconciled at the next save) instead of pushing. Saving the buffer pushes nothing. Re-introducing an auto-push on save is the separate "Automatic sync on save" task, which would call the save engine rather than this whole-file scanner. I kept pearl--extract-org-heading-properties and pearl--get-todo-states-pattern. They're general heading/keyword readers, not org-sync-specific, and the keyword-cycle save (the keyword-derivation follow-up) needs exactly them. The obsolete sync tests are replaced by two that assert the contract: the sync commands are gone, and a keyword cycle pushes nothing.
* refactor(save): retire the immediate-push field setters for buffer-editorsCraig Jennings2026-05-2510-289/+129
| | | | | | | | The structured fields now reconcile at save (the previous commits), so the immediate-push setters are the duplicate path that made the package feel like it had two save models. I retired them. pearl-set-priority is deleted outright β€” priority is edited org-natively through the heading cookie. pearl-set-state, pearl-set-assignee, and pearl-set-labels become pearl-edit-state / -assignee / -labels: the same completing-read picker, but it writes the buffer representation (keyword/name/id, the assignee drawer, the labels drawer plus the live LINEAR-LABEL-IDS set) and marks the field dirty instead of pushing. The change goes out at the next pearl-save-issue / pearl-save-all. pearl-compose-current-description is renamed pearl-edit-description (its compose-and-push behavior is unchanged β€” v2 doesn't touch the compose buffers). The transient, the C-; L e keymap, the affordance preamble, and the README follow the new names, and the priority slots are dropped (no command β€” it's the org cookie). Deleting the setters left pearl--push-issue-field and pearl--priority-choices with no callers, so both are gone, along with the killed-buffer test that only exercised the removed helper. I filed a follow-up: the atomic savers' async commit callbacks need the same killed-buffer guard that helper carried. The setter command tests are rewritten to assert the buffer-write-and-no-push contract. The reconcile-and-push path they used to cover is exercised in test-pearl-save.
* feat(save): reconcile structured fields in save-issue and save-allCraig Jennings2026-05-252-17/+87
| | | | | | The four structured savers now run from the save engine. pearl--save-field-thunks queues a priority / state / assignee / labels saver for each dirty structured field alongside title, description, and comments, so save-issue and save-all reconcile a changed cookie, keyword-picker id, assignee, or label set at save time like any other edit. save-all's confirm prompt and the dirty-scan gate both count the structured fields, and a new pearl--issue-has-dirty-fields-p replaces the title/description/comment check that was duplicated in save-issue and the file-wide scan. This is the commit that turns the engine on. The old immediate-push setters still exist until the next commit retires them, so for this one commit both paths are live β€” the setter cutover and the org-sync removal follow.
* feat(save): add the priority, state, and labels saversCraig Jennings2026-05-253-6/+215
| | | | | | | | | | These are three more savers on the atomic engine, each the same spec shape the assignee saver proved, with its own field handling. Priority reads the cookie's number, diffs it against LINEAR-PRIORITY, and pushes priority. State is the picker arm β€” the explicit LINEAR-STATE-ID against LINEAR-STATE-ID-SYNCED, pushing stateId, with use-remote rewriting the keyword and name from the fetched node (the keyword-cycle arm stays gated on the derivation task). Labels canonicalize the id set to a sorted space-joined string so the diff is order-insensitive, and push labelIds. Canonicalizing labels to a string also keeps the missing-baseline check honest: an issue with no labels has a present-but-empty baseline, which a nil-vs-empty-list comparison would misread as a legacy file. A shared set of pearl--node-* helpers pulls each field out of the raw issue node. I also fixed pearl--get-linear-priority-name, which mapped None to "Medium" β€” it now reads "None", so the conflict prompt names the priority honestly. It's still not wired into save-issue β€” the next commit does that.
* feat(save): add the atomic field-save engine and the assignee saverCraig Jennings2026-05-252-0/+219
| | | | | | | | pearl--run-atomic-field-save is the structured-field sibling of the text engine pearl--run-field-save. It runs the atomic three-way over a spec's live and baseline values. A missing baseline skips with missing-property (a legacy file β€” refresh first). Live equal to baseline is unchanged. Otherwise it fetches the remote and compares: remote already equal to live converges and advances the baseline, remote unmoved from baseline is a clean push, and a genuine divergence runs the atomic conflict gate. It reuses the existing save-outcome contract, so structured fields report the same status/reason shape as title and description. pearl--save-assignee-field is the first saver on it β€” the cleanest relation field. Its live id is LINEAR-ASSIGNEE-ID diffed against LINEAR-ASSIGNEE-ID-SYNCED, the remote comes from the issue node, the push sends assigneeId, and use-remote rewrites the display name from the fetched node. Priority, state, and labels follow the same template in the next commit. It's not wired into save-issue yet β€” that's the next commit. I tested it in isolation: the engine's three-way and conflict branches over a synthetic spec, plus the assignee saver end to end with a stubbed fetch and update.
* feat(save): add an atomic conflict gate for enum and relation fieldsCraig Jennings2026-05-252-0/+102
| | | | | | | | The text conflict gate (pearl--resolve-conflict) is built for mergeable text β€” it can open an smerge buffer, stash to the kill ring, and advance a SHA hash. None of that fits a state, priority, assignee, or label conflict, where the value is a single id or scalar that can't be combined. pearl--resolve-atomic-conflict is the sibling for those fields: it prompts use-local / use-remote / cancel (no merge option), and delegates the actual push and the baseline advance to the caller's two callbacks, so it stays free of any field-specific mechanics. It maps to the same outcome contract as the text gate β€” cancel to conflict/cancelled, use-local to pushed or failed/push-failed, use-remote to resolved-remote. It's not wired into a saver yet β€” the per-field savers in the next commit call it.
* feat(save): detect dirty structured fields against synced baselinesCraig Jennings2026-05-254-18/+128
| | | | | | The save scan now flags priority, state, assignee, and labels as dirty when their live value differs from the synced baseline, so structured fields ride the same reconcile-at-save path as title and description. Each check is id/scalar-only and runs with no network: the priority cookie's number against LINEAR-PRIORITY, the explicit state id against LINEAR-STATE-ID-SYNCED (the picker arm β€” keyword cycling lands later), the assignee id against its baseline, and the label id set against its baseline (order-insensitive, since Linear label order isn't meaningful). A missing baseline on a legacy file reads as not-dirty; the saver's missing-property guard covers that edge. This also makes the priority cookie faithful. The renderer mapped both None and Medium to [#C], so a None issue couldn't be told apart from a Medium one and would have read dirty the moment its baseline said 0. None now renders with no cookie, 1:1 with the four cookie levels β€” matching what the priority setter already did.
* build: resolve test-file to the exact name before globbingCraig Jennings2026-05-251-1/+9
| | | | make test-file FILE=test-pearl-save grabbed test-pearl-saved.el β€” the glob *FILE*.el matched both and head -1 picked one arbitrarily. It now prefers an exact FILE or FILE.el match, and errors with the candidate list when a bare substring matches more than one file, instead of silently running the wrong one.
* feat(save): write synced baselines for structured fieldsCraig Jennings2026-05-252-0/+32
| | | | | | Save-model v2 reconciles a field by diffing its live value against a baseline last synced from Linear, but the structured fields (priority, state, assignee, labels) only stored their current value β€” there was nothing to diff against. The renderer now writes a synced baseline beside each: LINEAR-PRIORITY (the numeric priority), LINEAR-STATE-ID-SYNCED, LINEAR-ASSIGNEE-ID-SYNCED, and LINEAR-LABEL-IDS / LINEAR-LABEL-IDS-SYNCED (the live and baseline id sets, with LINEAR-LABELS staying display-only names). At fetch, live equals baseline, so nothing reads dirty on a fresh issue. The merge refresh re-renders changed subtrees through the same renderer, so baselines advance there too, and a retained dirty subtree is left untouched.
* docs: add the ticket save-model v2 specCraig Jennings2026-05-252-0/+229
| | | | | | The v2 spec folds the structured fields β€” priority, state, assignee, labels β€” onto the save engine and removes immediate push, so the package has one write path instead of two. It extends the shipped v1 save-model spec and supersedes the "field setters stay immediate" half of v1 decision 8. I incorporated two Codex review rounds. I also added the supersession pointer to the top of the v1 spec so the v1/v2 relationship is legible from either file.
* refactor: rename issue-body tests to match entry-body-at-pointCraig Jennings2026-05-251-5/+5
|
* feat: render inline code spans verbatim in the Org bufferCraig Jennings2026-05-252-36/+75
| | | | | | | | A fetched description with markdown inside backticks β€” `**bold**`, `[text](url)`, `_italic_` β€” got its span contents converted along with the surrounding prose, so the Org buffer showed `*bold*` and `[[url][text]]` inside what should read as literal code. Markdown treats a code span as opaque; the converter didn't. Both converters now tokenize code spans first and apply link/bold/italic conversion only to the text between them, keeping span contents literal. The md->org and org->md directions each split into an emphasis-only helper (pearl--md-emphasis-to-org, pearl--org-emphasis-to-md) called on the gaps, with the span passed through untouched. Keeping the split symmetric is what preserves the round-trip identity a fetch + no-edit push depends on. A new test asserts the verbatim rendering for bold, link, and italic inside a span, plus emphasis still converting outside one and between two spans.
* refactor: rename pearl--issue-body-at-point to pearl--entry-body-at-pointCraig Jennings2026-05-256-27/+28
| | | | The helper returns the body of the Org entry at point, before any child heading. It started out reading issue descriptions, but comment editing and deletion reuse it for comment bodies too, so "issue" in the name was misleading. I renamed it across its callers and the sync/refresh/save/comment tests, and reworded the docstring to say "entry".
* refactor: drop the now-dead issue-id guards in the field settersCraig Jennings2026-05-251-6/+0
| | | | pearl-set-state, pearl-set-assignee, and pearl-set-labels each kept an (unless issue-id (user-error ...)) check that became unreachable once they switched to pearl--goto-issue-heading-or-error, which already signals when no enclosing issue heading carries LINEAR-ID. I removed the three dead guards; the climbing guard is the single source of that error now.
* fix: guard issue async callbacks against a killed bufferCraig Jennings2026-05-252-12/+45
| | | | | | pearl--push-issue-field (the field setters) and pearl-refresh-current-issue ran goto-char on the captured marker in their success callbacks with no liveness check. If the buffer was killed before the async response returned, the dead marker signaled "Marker does not point anywhere" from the callback. I wrapped the buffer work in a buffer-live-p check; the field setters still report the remote success, and refresh reports that the buffer was closed. This closes the last gap in the command/context test-coverage task. The other seven items it named were already covered by the quick-bug batch's tests; this adds the killed-buffer regression for the issue paths (delete-current-comment already had its own).
* docs: record the WorkflowState.position verification in the TODO-keywords specCraig Jennings2026-05-251-2/+6
| | | | I ran the live check the spec named as its one prerequisite: WorkflowState.position exists and returns a float (Dev Review 947.14, In Progress 2, Planning 0), so the per-team position ordering works as designed and the no-position fallback is unnecessary. Recorded it in the status and prerequisites and folded in the round-5 review (the only finding was that status/rubric hygiene). The spec is now cleanly READY with no open prerequisites.