From fb46b78003132d4281db2db1482118034e2424d8 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 22 Jul 2026 16:53:19 -0500 Subject: docs: collect specs under docs/specs/ I moved the specs from docs/ root into docs/specs/ and repointed their cross-references. --- docs/specs/comment-deletion-spec.org | 121 +++++ docs/specs/default-view-spec.org | 162 +++++++ docs/specs/interactive-grouping-spec.org | 180 +++++++ docs/specs/issue-comment-editing-spec.org | 117 +++++ docs/specs/issue-conflict-handling-spec.org | 74 +++ docs/specs/issue-query-spec.org | 258 ++++++++++ docs/specs/issue-representation-spec.org | 230 +++++++++ docs/specs/issue-sort-order-spec.org | 131 +++++ docs/specs/issue-sources-spec.org | 144 ++++++ docs/specs/labels-as-org-tags-spec.org | 121 +++++ docs/specs/local-and-linear-views-spec.org | 529 +++++++++++++++++++++ .../modified-ticket-indicator-spec-review.org | 110 +++++ docs/specs/modified-ticket-indicator-spec.org | 242 ++++++++++ docs/specs/multi-account-spec.org | 202 ++++++++ docs/specs/multi-state-filter-spec.org | 148 ++++++ docs/specs/refine-source-spec.org | 105 ++++ docs/specs/saved-query-sync-spec.org | 309 ++++++++++++ docs/specs/ticket-save-model-spec.org | 255 ++++++++++ docs/specs/ticket-save-model-v2-spec.org | 227 +++++++++ .../todo-keywords-from-workflow-states-spec.org | 217 +++++++++ 20 files changed, 3882 insertions(+) create mode 100644 docs/specs/comment-deletion-spec.org create mode 100644 docs/specs/default-view-spec.org create mode 100644 docs/specs/interactive-grouping-spec.org create mode 100644 docs/specs/issue-comment-editing-spec.org create mode 100644 docs/specs/issue-conflict-handling-spec.org create mode 100644 docs/specs/issue-query-spec.org create mode 100644 docs/specs/issue-representation-spec.org create mode 100644 docs/specs/issue-sort-order-spec.org create mode 100644 docs/specs/issue-sources-spec.org create mode 100644 docs/specs/labels-as-org-tags-spec.org create mode 100644 docs/specs/local-and-linear-views-spec.org create mode 100644 docs/specs/modified-ticket-indicator-spec-review.org create mode 100644 docs/specs/modified-ticket-indicator-spec.org create mode 100644 docs/specs/multi-account-spec.org create mode 100644 docs/specs/multi-state-filter-spec.org create mode 100644 docs/specs/refine-source-spec.org create mode 100644 docs/specs/saved-query-sync-spec.org create mode 100644 docs/specs/ticket-save-model-spec.org create mode 100644 docs/specs/ticket-save-model-v2-spec.org create mode 100644 docs/specs/todo-keywords-from-workflow-states-spec.org (limited to 'docs/specs') diff --git a/docs/specs/comment-deletion-spec.org b/docs/specs/comment-deletion-spec.org new file mode 100644 index 0000000..0af6f13 --- /dev/null +++ b/docs/specs/comment-deletion-spec.org @@ -0,0 +1,121 @@ +#+TITLE: pearl — Delete Current Comment Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-25 +#+STARTUP: showall + +* Status + +*Ready — reviews incorporated through round 3, live API verified (Codex + Craig, 2026-05-25).* The one gate (the =commentDelete= contract) is resolved by a live check — see Resolved API Facts. The dirty-local-delete policy is final (allow + discard-local-edits wording). No open questions remain; implementation can start. Modified recommendations are in Review Dispositions. + +Companion to [[file:issue-comment-editing-spec.org][issue-comment-editing-spec.org]] (the comment surface, viewer identity, and per-comment drawer this builds on). + +* Problem + +Pearl can render, create, and edit comments, but it can't delete one. The comment surface is =pearl-add-comment= (create), =pearl-edit-current-comment= (update your own), and the rendered headings — there is no delete. Removing a comment means leaving Emacs for the Linear web app. The Stage 2 keymap even leaves a natural empty slot for it (=C-; L d c=, "delete comment", beside =d t= "delete ticket"). + +* Current State + +- *Comments render* as headings under the =**** Comments= subtree, each carrying a drawer: =LINEAR-COMMENT-ID=, =LINEAR-COMMENT-AUTHOR-ID=, =LINEAR-COMMENT-SHA256=. +- *Ownership* is decided by =pearl--comment-editable-p= (author-id = viewer-id; a nil/empty author-id, meaning a bot or external comment, is never editable). The viewer is resolved by =pearl--viewer-async= and cached in =pearl--cache-viewer=. +- *Edit* (=pearl-edit-current-comment=) reads =LINEAR-COMMENT-ID= via =pearl--goto-heading-or-error=, refuses a non-own comment with no delete/update mutation, and pushes via =pearl--update-comment-async= (=commentUpdate=). It also computes a local-dirty check: =hash(pearl--org-to-md body) /= LINEAR-COMMENT-SHA256=. +- *Issue delete* (=pearl-delete-current-issue=) is the shape to follow: capture a marker, confirm with =yes-or-no-p=, fire =pearl--delete-issue-async=, and on success remove the subtree (=org-back-to-heading= -> =org-end-of-subtree= -> =delete-region=) and surface the buffer. +- *No comment delete exists* — no command, no =commentDelete= mutation, no binding. The keymap test currently asserts =C-; L d c= is intentionally unbound. + +* Resolved API Facts (live check, 2026-05-25) + +Verified against the live workspace by creating a throwaway issue + comment, running the raw mutation, and querying the comment back: + +- *Mutation:* =commentDelete(id: String!)= returns a payload with =success=. The live call returned ={"data":{"commentDelete":{"success":true}}}=. So =pearl--delete-comment-async= mirrors =pearl--delete-issue-async= exactly: callback with =(:success BOOL)=, error branch -> =(:success nil)=. +- *Recoverability:* querying =comment(id){ ... }= immediately after the delete returned ="Entity not found: Comment"= (=INPUT_ERROR=, 400) — *not* the soft-archive the published-schema =Comment.archivedAt= field had suggested. There is *no API restore path*: once deleted, the comment is gone from the API and Pearl cannot bring it back. (Linear's web UI may offer a momentary undo toast; that is not reachable from Pearl.) +- *Consequence:* undo/restore from Pearl is *impossible*, not merely deferred (it moves from "vNext if recoverable" to permanently out of scope). The prompt does not promise recoverability. +- *Permission / not-found behavior* was not exercised destructively beyond the own-comment success path; the permission gate (own-only, below) prevents a non-own =commentDelete= call in the first place, and a not-found id returns the same error shape, handled as =(:success nil)=. + +* Proposed Design + +** Command: =pearl-delete-current-comment= + +Runs from the comment heading or its body text before any child heading (the reach of =pearl--goto-heading-or-error=, matching =pearl-edit-current-comment=; it does not climb out of a nested Org heading authored inside the comment body — see Review Dispositions MP2). The flow: + +1. *Locate the comment.* =pearl--goto-heading-or-error=, then read =LINEAR-COMMENT-ID=. If absent, =user-error= "Not on a Linear comment" with no mutation. +2. *Permission gate (no delete mutation for non-own).* Resolve the viewer (=pearl--viewer-async=, reusing the cache; a cold cache costs one read-only lookup, not a mutation). If viewer resolution fails, refuse with no =commentDelete= call and the message "Could not determine your Linear identity; not deleting". If =LINEAR-COMMENT-AUTHOR-ID= is not the viewer (=pearl--comment-editable-p= nil), refuse with no =commentDelete= call ("You can only delete your own comments"). This matches edit's permission model. +3. *Local-dirty check, then confirm.* Compute =hash(pearl--org-to-md body)= against =LINEAR-COMMENT-SHA256=. Match (clean) -> standard wording. Differ (the user edited the body and is deleting instead of saving) -> the stronger discard-local-edits wording. *Missing* =LINEAR-COMMENT-SHA256= (a hand-edited or partially generated comment with no stored hash) -> treat as unknown provenance and take the stronger wording too, since the local state can't be confirmed clean. Delete proceeds on confirmation in every case (decision 2). =yes-or-no-p=, mirroring =pearl-delete-current-issue=. +4. *Delete.* Fire =pearl--delete-comment-async= (=commentDelete=). On =:success=, remove the comment's Org subtree (=org-back-to-heading= -> =org-end-of-subtree t t= -> =delete-region=) and surface the buffer. On failure, message and leave the subtree intact. + +No three-way conflict gate. A delete is unconditional against the remote — it doesn't merge, so remote drift since fetch doesn't change the outcome. The permission gate, the local-dirty wording, and the confirmation are the guards. + +*Prompt wording* (final, given the resolved recoverability) always names both the remote effect and the local removal: +- clean comment: "Delete this comment from Linear and remove it here?" +- dirty or unknown-provenance comment: "Delete this comment from Linear and discard your local edits here?" +Plain "Delete" rather than "Permanently delete": Pearl has no restore path (Resolved API Facts), but we don't claim permanence Linear's own UI may not honor. "remove it here" + the no-undo reality is the honest threshold. +Both name Linear and the local effect, so neither the remote delete nor a discarded local edit can be missed. + +** Async mutation: =pearl--delete-comment-async= + +Mirrors =pearl--delete-issue-async= and =pearl--update-comment-async=: =commentDelete(id: String!) { success }= (verified — see Resolved API Facts), callback with =(:success BOOL)=, error branch -> =(:success nil)=. + +** Callback safety + +The local removal operates on a marker captured *before* the async dispatch. The success callback checks the marker's buffer is still live and does nothing (no signal) if it was killed before the callback returned. At most one viewer lookup and one delete mutation per invocation. + +** Immediate, not part of the save model + +Delete is an immediate mutation, like the field setters and =pearl-delete-current-issue=. It does not fold into =pearl-save-issue= / =pearl-save-all=. It surfaces under the delete prefix and the transient for discoverability only. + +** Surfaces + +- *Keymap (Stage 2 slot):* =C-; L d c= -> =pearl-delete-current-comment=, label "delete comment", beside =d t= "delete ticket". The keymap test that asserts =d c= is unbound is inverted when this ships. +- *Transient:* =K= "delete comment" under the Delete group (=k= is "delete ticket"; =K= pairs with it). + +* Agreed Decisions + +1. Own-only, mirroring =pearl-edit-current-comment='s permission model; a non-own / bot / external comment is refused with no =commentDelete= mutation. +2. Dirty local comment (HP2 — *final*, Craig 2026-05-25): *allow* the delete, switching the confirmation to discard-local-edits wording when =hash(org->md body) /= LINEAR-COMMENT-SHA256= or the hash is missing (unknown provenance). This matches the immediate-delete model rather than refusing. (The rejected alternative was to refuse dirty comments and tell the user to save/revert/refresh first.) +3. "No delete mutation for non-own" is the precise guarantee, not "no network" — a cold viewer cache costs one read-only lookup (MP1). +4. Point reach is the comment heading or its pre-child body, matching current helpers; the ancestor-climbing helper is vNext (MP2). +5. Transient key =K=; keymap slot =C-; L d c= (MP3). + +* Open Questions (Craig) + +None — all resolved. The last gate (the live =commentDelete= contract) was verified 2026-05-25; the dirty-delete policy (decision 2) and the transient key =K= were already settled. + +* Files Touched + +- =pearl.el=: =pearl-delete-current-comment= (command), =pearl--delete-comment-async= (mutation), the transient =K= entry, the =C-; L d c= keymap binding. +- =docs/=: this spec. +- =README.org=: the comment section gains "delete your own comments". +- =tests/=: =tests/test-pearl-comment-deletion.el= (or additions to =test-pearl-delete.el=). + +* Test Plan + +- =pearl--delete-comment-async= success parses the =commentDelete= success payload; a false / GraphQL-error body returns =(:success nil)=. +- Not on a comment / missing =LINEAR-COMMENT-ID= -> =user-error=, no delete mutation. +- Own comment + confirmation accepted + success -> removes only that comment's subtree; sibling comments and the issue body intact. +- Confirmation declined -> no delete mutation, subtree intact. +- Non-own and bot/external comments -> no delete mutation (permission gate). +- Delete mutation failure (=:success nil=) -> subtree intact, failure reported. +- Dirty local comment -> the discard-wording confirmation path (decision 2); a clean comment -> the standard wording. The dirty prompt names both "Linear" and the local-edit discard. +- Missing =LINEAR-COMMENT-SHA256= -> takes the stronger (unknown-provenance) prompt, not the clean path. +- Viewer resolution failure -> the exact refusal message "Could not determine your Linear identity; not deleting", no delete mutation. +- Killed-buffer / lost-marker safety -> if the marker's buffer is gone when the callback fires, no signal and no attempt to surface a dead buffer. +- Keymap: =C-; L d c= binds =pearl-delete-current-comment= (inverts the current "no delete comment yet" assertion); transient contains the =K= delete-comment entry. +- Live-verified 2026-05-25: =commentDelete(id){success}= returned =success: true=, and the comment was not-found immediately afterward (no restore). The only remaining manual check is that a real own-comment delete removes the *right* comment subtree in a live buffer. + +* Review Dispositions + +*Round 1 (Codex, 2026-05-25).* Rubric =Needs research=. Accepted as written: HP1 (verify =commentDelete= before coding — recast as a blocking Implementation Prerequisite, with the partial published-schema finding recorded), MP1 ("no delete mutation for non-own", reworded throughout), MP3 (finalize =K= + the keymap slot), and the architecture / robustness / test-strategy / UX observations (callback-safety rule, prompt naming both effects, the expanded test plan). Two were modified: + +- *HP2 (local dirty comment) — modified to a choice.* The review offered two coherent options (allow-with-discard-wording, or refuse-dirty). Chose allow-with-discard-wording as the v1 decision because it matches the immediate-delete model — an explicit delete is already destructive, so discarding an unsaved body edit with clear wording is consistent, where refusing would make delete the one comment command that blocks on dirtiness. Flagged for Craig's confirmation since it's a data-loss call. +- *MP2 (point location) — modified to scope.* Adopted the narrow promise (reach = the comment heading or its pre-child body, matching =pearl--goto-heading-or-error=) rather than building the ancestor-climbing helper now. The shared climbing helper is recorded as a vNext candidate; building it here would widen the change beyond a thin delete command for a UX (nested headings inside a comment body) that isn't in use. + +Everything else accepted as written. + +*Round 2 (Codex, 2026-05-25).* Rubric still =Needs research= — HP1 (the live =commentDelete= check) is the one hard gate and stays a named prerequisite, unchanged. HP2 (the dirty-delete decided-and-also-open contradiction the response introduced) is resolved: Craig finalized allow-with-discard-wording, so it's now a settled decision and out of Open Questions. MP1 (prompt names both Linear and the local discard), MP2 (missing =LINEAR-COMMENT-SHA256= = unknown provenance -> stronger prompt + test), and MP3 (restore the exact viewer-failure message + test) accepted as written. No modifications or rejects this round. + +*Round 3 (Codex, 2026-05-25).* Rubric =Needs research=, sole blocker HP1 (the live =commentDelete= check), no other edits requested. Resolved by running the live check: created a throwaway issue + comment, ran =commentDelete= (=success: true=), confirmed the comment is not-found afterward (no API restore), and removed the test issue. Folded the verified contract into Resolved API Facts, finalized the prompt verb (plain "Delete"), and moved undo/restore from conditional-vNext to permanently out of scope. Rubric -> =Ready=. + +* Out of Scope + +- Bulk comment deletion. +- Undo / restore of a deleted comment from Emacs — *impossible*, not deferred: the live check found no API restore path (the comment is not-found immediately after =commentDelete=). +- A shared "find enclosing comment heading" helper that works from nested Org headings inside a comment body (vNext; would be shared with =pearl-edit-current-comment=). +- Deleting comments authored by others (Linear permissions forbid it; the gate enforces own-only). diff --git a/docs/specs/default-view-spec.org b/docs/specs/default-view-spec.org new file mode 100644 index 0000000..82467cc --- /dev/null +++ b/docs/specs/default-view-spec.org @@ -0,0 +1,162 @@ +#+TITLE: Spec: a configurable default view +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-02 + +* Status + +*Ready.* Codex review incorporated (2026-06-02, =Not ready= → resolved); the one blocker (HP1: the transient set-default-view key was unspecified and =D= is taken by save-locally) is settled by binding the transient suffix =.=, and the medium finding (MP1: missing test-surface drop-in task) is folded in. See *Review dispositions*. Triggered by the 2026-06-02 keybindings reconciliation: that work reserved =C-; L v D= for a not-yet-built set-default-view command and flagged the open question of whether the hot key =l= should run a configurable default instead of being hardwired to "my open issues." This spec settles four decisions before code — the value model, per-account vs global, the =l=-rebind question, and persistence — and is deliberately light: the feature is local config plus dispatch, with no external API surface, so no live probe is needed. Closest precedent in size is [[file:multi-state-filter-spec.org][multi-state-filter-spec.org]] (one short review round). + +* Problem + +Pearl's default fetch is hardwired. =pearl-list-issues= with no project runs =(:assignee :me :open t)= — "my open issues" (=pearl--list-issues-source=, =pearl.el:6100=) — and it's bound to the hot key =l= and to =C-; L f o=. There's no way to say "when I open Pearl, show me /this/ instead" — a particular local view, or a narrower slice than everything assigned to me. + +Most people live in one view. For one user it's their open issues; for another it's a curated local view ("My active sprint work"), or a team board they've saved locally. Today the second user runs =C-; L v l= and picks the view by name every time, while =l= — the shortest key — runs a fetch they rarely want. + +The goal: let =l= open whatever the user has chosen as their default, defaulting to "my open issues" so nothing changes for anyone who never sets one. + +* The four decisions + +These are the design calls worth settling on paper. The first is load-bearing (getting it wrong means a defcustom-shape change plus migration); the rest are contained. + +** Decision 1 — value model: nil or a local-view name + +The default is one of: + +- *unset (nil)* — "my open issues", today's behavior. +- *a local-view name* (a string keying =pearl-local-views=) — run that local view. + +A Linear Custom View is /not/ a direct default value. To default to a Linear view, save it locally first (=pearl-save-linear-view-locally=, copy-down) and set the resulting local view as the default. This keeps v1 from needing a second identification scheme (Linear views are id-keyed and need a fetch to resolve a name), and it costs the user nothing they can't already do. + +The defcustom type mirrors =pearl-default-account= exactly: =(choice (const :tag "My open issues" nil) (string :tag "Local view name"))=. + +** Decision 2 — per-account, not global (the load-bearing one) + +Local views carry an optional =:account= stamp and refuse to run under a different active account (=pearl--require-local-view-account=, =pearl.el:4349=). A single global default naming an account-tagged local view would therefore /refuse/ whenever a different account is active — the default would be broken half the time in a multi-account setup. + +So the default is *per-account when accounts are configured, global otherwise*, resolved through the active-account context the same way =:default-team-id= already is: + +- *Accounts mode* (=pearl-accounts= set): the default lives in each account's plist as =:default-view=, alongside the existing =:default-team-id= and =:org-file=. Each account names its own default, so a tagged local view is always run under the account it belongs to — the cross-account refusal can't fire. +- *Legacy mode* (=pearl-accounts= nil): the default lives in a standalone =pearl-default-view= defcustom. + +This parallels the existing =pearl-default-account= / per-account split exactly: a durable global preference for the simple case, per-account state for the multi-workspace case. The alternative (one global default that silently falls back to my-open-issues when its view is cross-account) was rejected — it makes =l= mean different things depending on which account is active, which is the confusion the per-account model removes. + +** Decision 3 — rebind =l= to the default; keep =pearl-list-issues= literal + +A new command =pearl-open-default-view= resolves the active account's default and dispatches: nil → run my-open-issues (=pearl-list-issues= with no project), a name → run that local view (=pearl-run-local-view=). Top-level =l= rebinds from =pearl-list-issues= to =pearl-open-default-view=. + +=pearl-list-issues= is *not* overloaded. It keeps its exact contract ("my open issues, assignee=me") because =pearl-list-issues-by-project= calls it with a project id and the literal fetch still needs a home — it stays at =C-; L f o=. So: + +- =C-; L l= (hot key) → =pearl-open-default-view= — your default. +- =C-; L f o= → =pearl-list-issues= — literal my-open-issues, unchanged. + +Back-compat holds by construction: with the default unset (every current config), =pearl-open-default-view= runs my-open-issues, so =l= does exactly what it does today. Setting a default is the only thing that changes =l='s behavior. + +*Transient key for set-default-view.* The prefix keymap reserves =C-; L v D= (D = Default) for =pearl-set-default-view=. The transient =pearl-menu= can't reuse =D= in its Views group: it's a flat keyspace and =D= already runs =pearl-save-linear-view-locally= there. So the transient binds =pearl-set-default-view= to =.= in the Views group ("set the current/default view"). The mismatch is expected and not worth re-keying the just-shipped save-locally suffix to fix: the keybinding reconciliation established that the transient's per-command letters are read off the screen and don't track the keymap's two-key chains. =v D= is the chain you type from memory; =.= is the slot you see in the menu. (The key is a free-slot pick, easy to change at implementation if a better one surfaces.) + +** Decision 4 — persistence: durable via customize + +A default is a durable preference, so the setter persists it the way =pearl-default-account= is meant to be set: through =customize-save-variable= so it survives a restart. In accounts mode the setter rewrites the active account's entry in =pearl-accounts= (updating its =:default-view= key) and saves; in legacy mode it sets and saves =pearl-default-view=. Session-only setting (a plain =setq= that evaporates on restart) is not the default behavior — a "default" the user has to re-set every session isn't one. + +The accounts-mode rewrite is *metadata-preserving*: it updates only =:default-view= on the active account's plist and leaves every other key (=:api-key-source=, =:org-file=, =:default-team-id=, =:url=, and anything the user added) intact, the same way local-view edits preserve unrelated metadata. And =pearl-accounts='s docstring and =:value-type= gain =:default-view= so the new key is visible to a Customize reader rather than silently undocumented. + +* Proposed commands + +** =pearl-set-default-view= (the reserved =C-; L v D=) + +Sets the active account's default view and persists it. Two entry paths: + +- *From a Pearl buffer whose source is a local view* — offers that view as the default ("Set 'My active sprint work' as the default view?"). The obvious "make what I'm looking at my default" gesture. +- *Otherwise* — =completing-read= over =pearl-local-views= names, with a =[ My open issues ]= sentinel at the top that clears the default back to nil. Cancelling leaves the default unchanged. + +Writes through =customize-save-variable= (Decision 4). In accounts mode it targets the active account's =:default-view=; in legacy mode =pearl-default-view=. + +** =pearl-open-default-view= (the hot key =l=) + +Resolves the active account's default (Decision 2) and dispatches (Decision 3): + +- nil → =pearl-list-issues= (my open issues). +- a local-view name that exists in =pearl-local-views= → =pearl-run-local-view= on it. +- a local-view name that no longer exists (deleted since it was set) → fall back to =pearl-list-issues= with a one-line message ("Default view 'X' no longer exists; showing my open issues"). Never errors — a stale default degrades to the universal default rather than blocking the shortest key in the keymap. + +A local view named as a per-account default is, by Decision 2, always tagged to (or shared with) the account it's stored under, so =pearl-run-local-view='s account guard passes by construction. + +* Current state + +- =pearl--list-issues-source= / =pearl-list-issues= (=pearl.el:6100-6134=) — the hardwired my-open-issues fetch; =l= and =f o= both point here today. +- =pearl-run-local-view= (=pearl.el:3857=) — runs a named local view through the account guard; the dispatch target for a name-valued default. +- =pearl-accounts= (=pearl.el:105=) — per-account plist; gains an optional =:default-view= key. =pearl-default-account= (=pearl.el:124=) is the value-type and persistence precedent. +- =pearl-prefix-map= (=pearl.el:6748=) — top-level =l= rebinds to =pearl-open-default-view=; =v D= (currently reserved/unbound) binds =pearl-set-default-view=; =f o= unchanged. +- The transient =pearl-menu= Views group currently binds =D= to =pearl-save-linear-view-locally= (=pearl-views-map= separately reserves =v D= for set-default-view). The transient gains a new =.= suffix for =pearl-set-default-view= (see Decision 3); the existing =D= suffix is untouched. + +*Unaffected:* =pearl-list-issues-by-project= (still calls =pearl-list-issues= with a project id), =pearl-refresh-current-view= (re-runs the buffer's recorded =#+LINEAR-SOURCE=, which already records whichever view was opened — a default-opened view refreshes like any other). + +* Migration + +None for existing configs. With no =:default-view= on any account and =pearl-default-view= nil, =pearl-open-default-view= runs my-open-issues, so =l= is unchanged. The feature is purely additive: a new defcustom (legacy), a new optional account plist key, and two new commands. No stored data changes shape. + +* Acceptance criteria + +- With no default set (legacy mode, =pearl-default-view= nil): =pearl-open-default-view= runs my-open-issues — same source plist =pearl-list-issues= produces today. +- With =pearl-default-view= set to a local-view name: =pearl-open-default-view= runs that local view (same path as =pearl-run-local-view=). +- With a default naming a non-existent local view: =pearl-open-default-view= falls back to my-open-issues and messages; it does not error. +- Accounts mode: the default resolves from the *active* account's =:default-view=; switching accounts switches which default =l= opens. +- Accounts mode: a default naming an account-tagged local view runs without tripping the cross-account guard. +- =pearl-set-default-view= from a local-view buffer offers that view; elsewhere it prompts over local-view names with a clear-to-my-open-issues sentinel. +- =pearl-set-default-view= persists through =customize-save-variable= — the choice survives a fresh Emacs (verified by re-reading the saved custom value, not a live =setq=). +- Accounts mode: setting the default rewrites only =:default-view= on the active account and leaves the account's other plist keys (=:api-key-source=, =:org-file=, =:default-team-id=, =:url=, any extras) intact. +- =pearl-accounts='s docstring and =:value-type= document the =:default-view= key. +- =pearl-list-issues= is unchanged in contract and still reachable at =C-; L f o=; =pearl-list-issues-by-project= still works. +- Top-level =C-; L l= resolves to =pearl-open-default-view=; =C-; L v D= resolves to =pearl-set-default-view=; the transient Views group exposes set-default-view under =.= with no duplicate suffix. +- Full ERT suite green, =make lint= and byte-compile clean. + +* Implementation phases (commits) + +1. *Resolver + open command* — =pearl-default-view= defcustom (legacy) + =:default-view= account-plist key (documented in =pearl-accounts='s docstring and =:value-type=); a =pearl--resolve-default-view= helper that returns the active scope's default (per-account in accounts mode, the defcustom in legacy); =pearl-open-default-view= dispatching nil → my-open-issues, name → local view, stale-name → fallback+message. Unit tests for the resolver (both modes) and the dispatch/fallback. (=feat:=) +2. *Setter* — =pearl-set-default-view= with the from-buffer and prompt paths, persisting via =customize-save-variable= to the right scope. The accounts-mode rewrite preserves unrelated account plist keys (metadata-preserving). Tests for scope targeting, plist-key preservation, and the clear-to-nil sentinel. (=feat:=) +3. *Keymap + transient + docs* — rebind top-level =l= to =pearl-open-default-view=, bind =v D= to =pearl-set-default-view=, add the =.= set-default-view suffix to the transient Views group; keymap and menu tests updated (=v D= and transient =.= resolve, no duplicate suffix); README documents the default view, the =l= behavior, and the per-account scoping. (=feat:= + doc changes ride along) + +* Out of scope (vNext) + +- A Linear Custom View as a direct default value (save it locally first; see Decision 1). +- Auto-opening the default view on Emacs startup or on visiting the account's org file — v1 is the command + the =l= binding only. +- An ad-hoc filter (not saved as a local view) as a default. +- Per-project or context-sensitive defaults. + +* Implementation tasks (drop-in for todo.org) + +#+begin_src org +,** TODO [#B] Default view — resolver + open command :feature: +=pearl-default-view= defcustom (legacy) + =:default-view= account-plist key; =pearl--resolve-default-view= returns the active scope's default (per-account in accounts mode, defcustom in legacy); =pearl-open-default-view= dispatches nil → my-open-issues, a local-view name → =pearl-run-local-view=, a stale name → my-open-issues + message (never errors). Spec: [[file:docs/specs/default-view-spec.org]] (phase 1). + +,** TODO [#B] Default view — set-default-view command :feature: +=pearl-set-default-view= (the reserved =C-; L v D=): from a local-view buffer offers that view; otherwise prompts over local-view names with a clear-to-my-open-issues sentinel. Persists via =customize-save-variable= to the active account's =:default-view= (accounts mode) or =pearl-default-view= (legacy). Spec: [[file:docs/specs/default-view-spec.org]] (phase 2). + +,** TODO [#B] Default view — keymap, transient, docs :feature: +Rebind top-level =C-; L l= to =pearl-open-default-view= (=f o= stays literal my-open-issues); bind =C-; L v D= to =pearl-set-default-view=; add the =.= set-default-view suffix to the transient Views group (=D= stays save-locally). Update keymap + menu tests + README (default view, =l= behavior, per-account scoping). Spec: [[file:docs/specs/default-view-spec.org]] (phase 3). + +,** TODO [#B] Default view — test surface :test: +Unit: =pearl--resolve-default-view= returns =pearl-default-view= in legacy mode and the active account's =:default-view= in accounts mode; the accounts-mode setter rewrite updates only =:default-view= and preserves other plist keys; stale-name fallback. Command flow: nil default dispatches to =pearl-list-issues= (no project), an existing local-view name to =pearl-run-local-view=, a stale name to =pearl-list-issues= with the fallback message; setter from a local-view buffer offers that view, from elsewhere prompts with the clear sentinel, cancel leaves state unchanged; persistence calls =customize-save-variable= for the right scope. Surface: =C-; L l= → =pearl-open-default-view=, =C-; L f o= → =pearl-list-issues=, =C-; L v D= → =pearl-set-default-view=, transient Views =.= → set-default-view with no duplicate suffix. E2e/manual-verify: =C-; L l= opens the configured default; switching accounts switches which default opens. Spec: [[file:docs/specs/default-view-spec.org]] (Acceptance criteria). +#+end_src + +* Review dispositions + +Codex returned =Not ready= with one blocker and one medium; both accepted. + +- *HP1 (transient set-default-view key unspecified; =D= taken) — accepted.* The spec now binds the transient suffix =.= for =pearl-set-default-view= in the Views group, leaves the shipped =D= = save-locally suffix untouched, and explains why the transient key differs from the keymap's =v D= (flat keyspace, read off the screen — the same surface-divergence the keybinding reconciliation established). Decision 3, the Current-state bullet, acceptance, phase 3, and the phase-3 drop-in task all name =.= now. The key is a free-slot pick, flagged as easy to change at implementation. +- *MP1 (missing =:test:= drop-in task) — accepted.* Added a fourth drop-in task, =Default view — test surface :test:=, mirroring the acceptance criteria (resolver in both modes, plist-key preservation, stale-name fallback, dispatch, setter paths, persistence, surface, e2e). + +Supporting recommendations folded in: the Current-state transient fact corrected to the real binding (=D= = save-locally, =v D= reserved); the metadata-preserving account rewrite (preserve unrelated plist keys) added to Decision 4, acceptance, phase 2, and the test surface; =pearl-accounts='s docstring/=:value-type= documenting =:default-view= added to acceptance and phase 1. The remaining test-strategy points were already covered by acceptance and the new test-surface task. Status raised to Ready. + +* Review and iteration history + +** 2026-06-02 Tue @ 12:18:21 -0500 — Codex — reviewer + +- *What changed or was recommended:* Reviewed the spec against the current local-view, account, issue-list, keymap, transient, README, tests, and task-tracking surfaces. Verdict: =Not ready=. One high-priority blocker: the spec asks the transient Views group to expose =pearl-set-default-view= but does not choose a flat transient key, and =D= is already used by =pearl-save-linear-view-locally=. One medium finding: the drop-in task block needs the required test-surface entry. +- *Why:* The core feature design is coherent, but command-surface keys are visible UX and must not be invented during implementation. The task handoff also needs a dedicated test entry so acceptance coverage survives into =todo.org=. +- *Artifacts:* =default-view-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-02 Tue @ 12:30:50 -0500 — Claude Code (pearl) — responder + +- *What changed:* Accepted both findings. HP1: bound the transient suffix =.= to =pearl-set-default-view= (keeping =D= = save-locally), with Decision 3 explaining the keymap/transient key divergence; updated the Current-state bullet, acceptance, phase 3, and the phase-3 drop-in task to name =.=. MP1: added the fourth =:test:= drop-in task. Folded the supporting recommendations: corrected the Current-state transient fact, added the metadata-preserving account rewrite to Decision 4 / acceptance / phase 2 / test surface, and added =pearl-accounts= docstring/=:value-type= documentation to acceptance and phase 1. Raised status to Ready. +- *Why:* The blocker was a real visible-UX gap — naming the transient key now keeps the implementer from inventing command surface mid-build. The plist-preservation and docstring points prevent a setter that clobbers account config or an undocumented Customize key. +- *Artifacts:* Review file deleted on completion. diff --git a/docs/specs/interactive-grouping-spec.org b/docs/specs/interactive-grouping-spec.org new file mode 100644 index 0000000..60e5541 --- /dev/null +++ b/docs/specs/interactive-grouping-spec.org @@ -0,0 +1,180 @@ +#+TITLE: pearl — Interactive Grouping Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-06 +#+STARTUP: showall + +* Status + +*Ready — review rounds 1-2 (Codex) incorporated (Craig, 2026-06-06).* Companion to [[file:issue-sort-order-spec.org][issue-sort-order-spec.org]]: that doc added "sort the view you're looking at" without hand-editing a source; this one adds "group the view you're looking at." The two are siblings — both change how the active buffer is laid out, both persist on the source so a refresh reproduces them, and both must move existing subtrees rather than re-render from parsed data so unsaved edits survive. + +Round 1 closed four blocking gaps: the missing implementation-phase plan, the =:client-group= resolver contract across every grouping consumer, the false "cycle is in the buffer" assumption (cycle isn't rendered into the drawer, so it's deferred from v1), and undefined malformed-subtree placement. Round 2 (rubric =Ready with caveats=, no HP) cleaned up a stale "all five dimensions" sentence and added the explicit test-surface task. Dispositions are in Review dispositions below. + +* Problem + +Grouping is server-driven only. When a Linear Custom View is configured with an =issueGrouping=, pearl reads it, stamps =:group= on the source, and renders issues under level-2 group headings (issues at level 3). But to group a buffer that has no grouping — My Open Issues, a filter, or a Linear view with grouping unset — the user has to open Linear's web UI, set the grouping there, and re-fetch. There's no "group this by status" command for the view in front of you. + +* Current state + +- =pearl--group-issues= partitions normalized issues into ordered groups by a grouping string; =pearl--view-group-label= maps each issue to its bucket ("No project" etc. for an unset dimension). Supported dimensions: =workflowState=, =project=, =assignee=, =priority=, =cycle= (single-valued; label grouping and sub-grouping are deferred). +- =pearl--build-org-content= renders the grouped shape: group headings at level 2, issues at level 3, group headings carry no =LINEAR-ID= so save/merge skip them like the help header. An ungrouped source renders the flat level-2 list. +- The merge-refresh path is grouping-aware: =pearl--merge-append-grouped= places a new issue under its group section, =pearl--merge-issues-into-buffer= takes a grouping argument, and the reorder/merge preserve heading level. Every consumer reads =(plist-get source :group)= directly today: =pearl--build-org-content=, the dirty-merge branch of =pearl--update-org-from-issues=, =pearl--merge-query-result=, and =pearl--merge-issues-into-buffer= / =pearl--merge-append-grouped=. The =:client-group= resolver (below) must replace each of those reads, or refresh will redraw the server grouping and ignore the user's interactive choice. +- =:group= lives on the source plist and persists in =#+LINEAR-SOURCE=, so a refresh reproduces the grouping. It is only ever set from the view's Linear preference today — never interactively. +- The drawer data contract is the constraint on which dimensions v1 can offer. =pearl--format-issue-as-org-entry= (pearl.el:2882) renders =LINEAR-STATE-NAME=, =LINEAR-PROJECT-NAME=, =LINEAR-ASSIGNEE-NAME=, and =LINEAR-PRIORITY= into the drawer — so status, project, assignee, and priority are all recoverable from buffer text. It does *not* render cycle. The normalized issue carries =:cycle=, but only at render time; once an issue is on disk its cycle is gone. So an offline client-side regroup can group by the first four dimensions but not by cycle. Cycle is deferred from v1 (see Review dispositions HP3); a separate task adds cycle drawer fields and then re-enables cycle grouping. +- =pearl-set-sort='s client reorder (=pearl--reorder-issue-subtrees=) moves whole subtrees byte-for-byte at a fixed level. It refuses on a grouped buffer (returns =grouped=), because it walks the parent's direct level-2 children and a grouped buffer has group headings there instead. + +* Proposed design + +** The command + +=pearl-set-grouping= (interactive, in the active file): =completing-read= the dimension (=status= / =project= / =assignee= / =priority= / =none=), coerce the display string to the Linear =issueGrouping= value the render path already speaks, regroup the buffer, persist on the source. =none= ungroups back to the flat level-2 list. =cycle= is deferred from v1 — it isn't in the drawer (see Current state). =pearl--check-grouping= refuses an unknown dimension before any buffer change or header write, mirroring =pearl--check-sort-order=; a legacy =:group= / =:client-group= value pearl doesn't section renders flat, matching the current render helpers. Transient/keymap placement deferred to a follow-up, same as sort shipped M-x-only in v1. + +** Client-side only — group what you're looking at + +All v1 dimensions (status, project, assignee, priority) are recoverable from the drawer fields already in the buffer, so grouping is a pure client-side re-layout: no refetch, instant, works offline. This is simpler than sort's split (sort needs the server for =updated=/=created=); grouping has no server branch in v1. (Cycle would need a refetch or a representation change, which is why it's deferred — see Current state.) + +** Regroup in place: move subtrees, shift their level, never reconstruct (the crux) + +Regrouping changes an issue's outline level — flat (level 2) becomes grouped (level 3 under a level-2 group heading), and =none= reverses it. The reorder must preserve unsaved edits the same way sort does, so it moves existing subtree text rather than re-rendering from parsed data. The added wrinkle over sort is the level shift. The command: + +1. Captures each issue subtree's region text byte-for-byte (description, comments, drawers, unsaved edits intact), keyed by =LINEAR-ID=. +2. Computes each issue's group label from the *buffer* data, not a refetched plist: reads the relevant drawer field (=LINEAR-STATE-NAME= / =LINEAR-PROJECT-NAME= / =LINEAR-ASSIGNEE-NAME= / =LINEAR-PRIORITY=) for the chosen dimension and maps it to a bucket label the same way =pearl--view-group-label= does (a small buffer-reading label helper, or reparse a minimal plist per subtree and reuse =pearl--view-group-label=). Computes the target level: 3 when grouping, 2 when =none=. +3. Shifts every heading line within each captured subtree by the level delta — the issue heading and its comment children all move by the same +1 (group) or -1 (ungroup). Only the leading stars change; body, drawers, and comment text are untouched, so edits survive. +4. Emits the level-2 group headings in group order and lays each group's issue subtrees beneath, then replaces the old issue block in one delete+insert. +5. Re-folds afterward (=pearl--restore-page-visibility=) — the same fix b589445 applied to sort, since this rewrites the issue block too. +6. A subtree with no =LINEAR-ID= (a malformed or non-issue subtree) is never dropped and gets a defined, stable position: kept after all issue groups when grouping, and after all issue subtrees when ungrouping, preserving the non-issue subtrees' original relative order (mirroring sort's "sorted last, stable"). + +A test edits a description and a comment locally, groups by status, and asserts the exact edited text is intact under the right group heading at the right level. + +** Persistence, the effective-grouping resolver, and every consumer + +Mirror the sort model, with one resolver every grouping consumer routes through. Interactive grouping is recorded under =:client-group= for *all* source types (uniform key — simpler command code, one resolver), via =pearl--source-with-grouping= (analogous to =pearl--source-with-sort=). A view keeps its own Linear grouping in =:group=; a filter leaves =:group= nil. =none= clears =:client-group= so a view falls back to its server =:group= on the next refresh, and a filter falls back to flat. + +=pearl--effective-grouping= (analogous to =pearl--effective-sort-order=) returns =:client-group= when present, else =:group=. This is the contract's load-bearing piece: every place that reads =(plist-get source :group)= today must read the effective value instead, or refresh redraws the server grouping and the interactive choice silently vanishes. The call sites to convert: + +- =pearl--build-org-content= (full render). +- the dirty-merge branch of =pearl--update-org-from-issues=. +- =pearl--merge-query-result= (refresh). +- =pearl--merge-issues-into-buffer= / =pearl--merge-append-grouped= (new-issue placement). + +=#+LINEAR-SOURCE= persists both keys where applicable: a view's =:group= stays recorded alongside any =:client-group= override, so clearing the override restores the view's own grouping without a re-fetch. + +** Interaction with sort + +=pearl--group-issues= keeps each group's issues in input order, so a client sort already applied to the buffer survives inside every section. And once grouping is interactive, sorting within groups (the filed [#C] "interactive sort in a grouped view", currently refused) becomes the natural next step — out of scope here, noted so the two features stay aligned. + +** Outcome messages + +"Grouped current buffer by status" / "Ungrouped current buffer." On a buffer with no issues, "No issues to group in this buffer." (parallel to sort's messages). + +* v1 decisions (this feature) + +1. Command: =pearl-set-grouping=, =completing-read= dimension incl. =none=. M-x only; transient/keymap deferred. +2. Client-side only — no refetch; group labels come from the buffer drawer fields. +3. Regroup moves subtrees byte-for-byte with a per-subtree heading-level shift; edits preserved; re-fold after. +4. Persist interactively-chosen grouping under =:client-group= for all source types (uniform key); =pearl--effective-grouping= prefers it over a view's server =:group=; =none= clears it. Every current =:group= consumer routes through the resolver. +5. Group order: first-appearance, matching the existing grouped render. Natural per-dimension order is vNext (MP1). +6. Supported dimensions: status / project / assignee / priority. Cycle deferred (not in the drawer — HP3); label and sub-grouping deferred. +7. =pearl--check-grouping= refuses unknown interactive input before any change; a legacy unsectioned =:group= / =:client-group= renders flat (MP3). +8. Malformed / non-issue subtrees kept stable and last — after all groups when grouping, after all issues when flat (HP4). + +* Files touched + +- =pearl.el= — =pearl-set-grouping= command; =pearl--grouping-choices= + display→value coercion; =pearl--check-grouping= validator; =pearl--source-with-grouping= + =pearl--effective-grouping= resolver; the regroup-in-place helper (subtree capture + buffer-read label + level shift + group emit + malformed-last), parallel to =pearl--reorder-issue-subtrees=; convert the four =:group= consumers to the resolver; header persistence; re-fold call. No GraphQL/network code — v1 touches rendered buffer text plus the source header only. +- =tests/test-pearl-grouping.el= (new or extend) — partition already covered; add resolver precedence, =pearl--source-with-grouping= persistence/clear, regroup-in-place (level shift, edit preservation, ungroup round-trip, malformed placement both directions), and the refresh paths consuming effective grouping. +- =README.org= — a "Grouping the current view" subsection alongside "Sorting the current view". + +* Test plan + +- Resolver: =pearl--effective-grouping= on a view with server =:group=, a view with =:client-group=, a cleared =:client-group=, and a non-view source. +- Persistence: =pearl--source-with-grouping= writes =:client-group= to =#+LINEAR-SOURCE=; a refresh reproduces the grouping; =none= clears it so a view falls back to its server grouping and a filter falls back to flat. +- Refresh integration: the full render and both merge-refresh paths group by the effective value, not raw =:group=. +- Normal: group a flat buffer by status — issue headings shift level 2→3, group headings at level 2, in first-appearance order; "No status" bucket for unset. +- Boundary: =none= on a grouped buffer shifts issue headings 3→2 and removes group headings; group→none round-trips to the flat shape. +- Edit preservation: a locally-edited description and comment survive the regroup (exact-string), under the right group at the right level. +- Malformed: a non-issue subtree lands last and stable in both directions. +- Validation: unknown interactive input refused before any header write; a legacy unsectioned =:group= renders flat. +- No-issues buffer: refuses with a message, no header write. + +* Implementation phases + +1. *Source + resolver plumbing.* =pearl--grouping-choices= + display→value coercion, =pearl--check-grouping=, =pearl--source-with-grouping=, =pearl--effective-grouping=, header write/clear. Tests: coercion, validation, persistence/clear, resolver precedence. +2. *Buffer regroup core.* Capture issue + non-issue subtrees; compute each issue's label from drawer data; shift heading levels; emit grouped/flat block; malformed-last; re-fold. Tests: level shift both directions, edit preservation, round-trip, malformed placement. +3. *Command + UX.* =pearl-set-grouping= completion, outcome messages, no-issues and missing-=#+LINEAR-SOURCE= handling (shared active-source error). +4. *Refresh / render integration.* Convert =pearl--build-org-content=, the dirty-merge branch, =pearl--merge-query-result=, and =pearl--merge-issues-into-buffer= / =pearl--merge-append-grouped= to the resolver. Tests: each path groups by effective value. +5. *Docs.* README "Grouping the current view"; source-level naming/coverage check. + +** Implementation tasks (drop-in for todo.org) + +#+begin_src org +** TODO [#B] Implement interactive grouping spec :feature: +Spec: docs/interactive-grouping-spec.org (Ready). Group the active buffer on demand, client-side, parallel to pearl-set-sort. +*** TODO [#B] Grouping source + resolver plumbing :feature:solo: +pearl--grouping-choices + coercion, pearl--check-grouping, pearl--source-with-grouping, pearl--effective-grouping, header write/clear. Phase 1. +*** TODO [#B] Buffer regroup core (capture, level-shift, malformed-last, re-fold) :feature:solo: +Phase 2. Move subtrees byte-for-byte; read group label from drawer; shift heading stars by the level delta; non-issue subtrees last+stable; re-fold after. +*** TODO [#B] pearl-set-grouping command + UX :feature:solo: +Phase 3. completing-read dimension incl. none; outcome messages; no-issues + missing-source handling. +*** TODO [#B] Route every grouping consumer through pearl--effective-grouping :feature:solo: +Phase 4. build-org-content, dirty-merge branch, merge-query-result, merge-issues-into-buffer / merge-append-grouped. +*** TODO [#C] README "Grouping the current view" + naming check :docs:solo: +Phase 5. +*** TODO [#B] Interactive grouping test surface :test:solo: +Cover resolver precedence, header persistence/clear, effective grouping in render and both merge-refresh paths, level shift both directions, edit preservation, malformed-last placement, validation, and no-issues behavior. Spec: docs/interactive-grouping-spec.org (Test plan). +#+end_src + +* vNext / out of scope + +- Grouping by cycle — blocked on rendering cycle into the issue drawer (filed [#C] "Render cycle into the issue drawer"); re-enable =cycle= in =pearl-set-grouping= once it lands. +- Interactive sort within a grouped view (filed [#C]) — pairs with this feature. +- Label grouping and two-level sub-grouping (an issue with several labels files under each; Linear's =issueGrouping= + sub-grouping). +- Natural per-dimension group ordering (priority Urgent→None, workflow-state position); v1 ships first-appearance. +- Transient/keymap entry for =pearl-set-grouping=. + +* Review dispositions + +Round 1 (Codex, 2026-06-06) — rubric Not ready, 4 HP + 3 MP. All accepted; HP3 resolved by deferral. + +- *HP1 (no implementation phases / drop-in task block)* — accepted. Added Implementation phases (5) + the drop-in todo.org block. +- *HP2 (define the effective-grouping resolver and route every consumer)* — accepted. Added =pearl--effective-grouping=; enumerated the four call sites (=build-org-content=, dirty-merge branch, =merge-query-result=, =merge-issues-into-buffer= / =merge-append-grouped=) that must switch from raw =:group=. +- *HP3 (cycle isn't in the buffer)* — accepted, resolved by deferring cycle from v1. Verified against pearl.el:2882: the drawer renders state/project/assignee/priority but not cycle. v1 offers the four buffer-backed dimensions; cycle grouping is vNext, gated on a separate task that adds cycle drawer fields. Filed [#C] "Render cycle into the issue drawer". Chosen over adding cycle fields now because that grows every rendered issue and touches the save/merge hashing surface — a bigger change than this feature should carry. +- *HP4 (malformed-subtree placement undefined)* — accepted. Defined: non-issue subtrees kept last and stable, after all groups when grouping and after all issues when flat. Tests both directions. +- *MP1 (group order)* — accepted. First-appearance locked for v1; natural per-dimension order to vNext. +- *MP2 (filter source-key model)* — accepted. Uniform =:client-group= for all source types; =:group= reserved for a view's own Linear grouping. +- *MP3 (unknown-value validation)* — accepted. =pearl--check-grouping= refuses unknown interactive input before any change; a legacy unsectioned value renders flat. + +Round 2 (Codex, 2026-06-06) — rubric Ready with caveats, no HP, 2 MP. Both accepted; both cleanups. + +- *R2-MP1 (stale "all five dimensions" sentence)* — accepted. The Client-side-only section still claimed all five dimensions were buffer-computable, reintroducing the HP3 error. Rewritten to name the four v1 dimensions and note cycle's deferral. +- *R2-MP2 (no explicit test-surface task)* — accepted. Added =[#B] Interactive grouping test surface :test:solo:= to the drop-in todo block, per the workflow's one-test-entry requirement. Rubric raised to Ready. + +* Review and iteration history + +** 2026-06-06 Saturday @ 13:21:52 -0500 — Codex — reviewer + +- *What changed or was recommended:* Ran the spec-review workflow. Rubric =Not ready=. Wrote a blocking review covering missing implementation phases, incomplete =:client-group= effective-grouping plumbing, the false rendered-data assumption for =cycle= grouping, and undefined malformed-subtree placement. +- *Why:* The spec has the right user-facing direction but still leaves implementation-critical state, refresh, and handoff contracts for the implementer to invent. +- *Artifacts:* [[file:interactive-grouping-spec-review.org][interactive-grouping-spec-review.org]]; deferred vNext items logged in [[file:../todo.org][todo.org]]. + +** 2026-06-06 Saturday @ 13:48:12 -0500 — responder + +- *What changed or was recommended:* Dispositioned all 7 findings (4 HP + 3 MP), all accepted — see Review dispositions. Added Implementation phases + the drop-in todo.org block (HP1); added =pearl--effective-grouping= and named its four consumers (HP2); deferred cycle from v1 and corrected the data-contract claim in Current state (HP3); defined malformed-subtree placement (HP4); locked first-appearance order (MP1), uniform =:client-group= (MP2), and =pearl--check-grouping= validation (MP3). Status raised Draft → Ready. +- *Why:* The review was right on every point; HP3 was a genuine factual error in the draft (cycle isn't rendered into the drawer, verified at pearl.el:2882). +- *Artifacts:* this spec; filed [[file:../todo.org][todo.org]] [#C] "Render cycle into the issue drawer (unblocks grouping by cycle)". Review file deleted after fold-in. + +** 2026-06-06 Saturday @ 13:56:18 -0500 — responder (round 2) + +- *What changed or was recommended:* Round 2 rubric =Ready with caveats=, no HP, 2 MP — both accepted (see Review dispositions). Rewrote the stale "all five dimensions" sentence in Client-side-only to name the four v1 dimensions plus cycle's deferral (R2-MP1); added the standalone =[#B] ... test surface :test:solo:= task to the drop-in block (R2-MP2). Rubric raised to Ready. +- *Why:* Both were document-consistency cleanups, not design changes; the stale sentence re-asserted the round-1 cycle error and the workflow wants a discrete test task in the handoff block. +- *Artifacts:* this spec. Round-2 review file deleted after fold-in. + +** 2026-06-06 Saturday @ 13:52:41 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow after the response pass. Rubric =Ready with caveats=. No high-priority blockers remain; recommended fixing one stale "all five dimensions" sentence and adding the explicit test-surface task required by the workflow. +- *Why:* The round-1 design blockers are resolved, but the spec still has two small handoff/document consistency issues to clean up before marking fully =Ready=. +- *Artifacts:* [[file:interactive-grouping-spec-review.org][interactive-grouping-spec-review.org]]. + +** 2026-06-06 Saturday @ 14:15:36 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow after the round-2 response. Rubric =Ready=. Confirmed the stale "all five dimensions" sentence was corrected to the four drawer-backed v1 dimensions, and the drop-in todo block now includes the required standalone test-surface task. +- *Why:* The prior caveats are resolved and no remaining implementation-blocking ambiguity was found. +- *Artifacts:* this spec; no new review file was written because the spec is implementation-ready. diff --git a/docs/specs/issue-comment-editing-spec.org b/docs/specs/issue-comment-editing-spec.org new file mode 100644 index 0000000..6a1b0c8 --- /dev/null +++ b/docs/specs/issue-comment-editing-spec.org @@ -0,0 +1,117 @@ +#+TITLE: pearl — Comment Editing Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*APPROVED — open questions resolved 2026-05-24 (see [[*Resolved decisions][Resolved decisions]]). Implementation in progress.* Nothing in =pearl.el= had changed at the time of writing. + +Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (rendering + description/title editing) and [[file:issue-query-spec.org][issue-query-spec.org]] (fetching). This doc covers the one editing path those two parked as vNext: editing an existing comment. It reuses their org→Linear write path, their conflict-gate pattern, and the single active-file model. + +* Problem + +Comments are render-and-add only. You can read the thread and post a new comment, but you can't fix a typo in your own comment without leaving Emacs for the Linear web UI. Linear lets a user edit only their own comments, so the feature has to carry a permission check: a comment authored by someone else (or by a bot or integration) must not be editable from Emacs, and the attempt must fail clearly rather than bounce off the server with an opaque error. + +The representation spec already parked this (its decision 2): "editing existing comments is vNext, and then only comments authored by the current Linear user, matching Linear's permissions." This is that vNext. + +* Current state (what exists today) + +- *Fetch.* =pearl--fetch-issue-async= (=pearl.el:~737=) pulls each comment as =id=, =body=, =createdAt=, =user { id name displayName }=, =botActor { name }=, =externalUser { name }=. The single-issue fetch carries comments; the bulk list omits them. +- *Normalize.* =pearl--normalize-comment= (=l.568=) returns =(:id :body :created-at :author)=. The =:author= is the *display name only* — the user's =id= is fetched but dropped. There is no viewer identity anywhere in the package. +- *Render.* =pearl--format-comment= (=l.1612=) renders =***** = followed by the body (markdown → org). The comment =id= is not written into the org; nothing per-comment is recoverable after render. +- *Add.* =pearl--create-comment-async= (=l.1949=, =commentCreate=) + =pearl--append-comment-to-issue= (=l.1975=). +- *Conflict pattern to reuse.* Description sync (representation spec) hashes the last-fetched body into =LINEAR-DESC-SHA256=, compares last-fetched / current-org / current-remote, and does no-op / push / refuse-on-both-changed. + +Three things are therefore missing for editing: the *viewer's identity*, per-comment *id + author id + provenance* in the org, and a =commentUpdate= write path with the same conflict gate. + +* Proposed design + +** 1. Viewer identity + +Add an async =viewer { id name }= query with a cached id, mirroring the team/state caches: + +- =pearl--viewer-async (callback)= → normalized =(:id :name)=. +- =pearl--viewer-id= → cached id, fetched once per session. +- Add the viewer cache to =pearl-clear-cache=. + +This is the identity the permission check compares against. + +** 2. Retain the comment author id + +Extend =pearl--normalize-comment= to keep =:author-id= (the =user.id=). Bot and external comments have no editable user, so =:author-id= is nil for them — which the permission check reads as "not editable." + +** 3. Per-comment provenance in the org + +To target a comment for =commentUpdate= and to decide editability, each rendered comment heading needs its id, its author id, and a body hash. A small property drawer under each =*****= comment heading, mirroring the issue drawer: + +#+begin_src org +**** Comments +***** Craig — 2026-05-24T10:00:00.000Z +:PROPERTIES: +:LINEAR-COMMENT-ID: +:LINEAR-COMMENT-AUTHOR-ID: +:LINEAR-COMMENT-SHA256: +:END: +The comment body renders here as org, edited in place. +#+end_src + +=org-tidy= folds the drawer the same way it folds the issue drawer, so the thread still reads cleanly. The =SHA256= is the last-fetched-body provenance for the conflict gate, exactly like =LINEAR-DESC-SHA256=. + +** 4. The edit command + +=pearl-edit-current-comment= (name is an open question), run from anywhere inside a comment's subtree: + +1. Locate the enclosing =*****= comment heading and read its drawer. +2. *Permission gate.* If =LINEAR-COMMENT-AUTHOR-ID= is empty or ≠ the viewer id, =user-error= "You can only edit your own comments" and stop. No network call. +3. Render the comment's current org body to markdown (the description sync's org→md path). +4. *Conflict gate* (mirrors description sync, v1 = detect / refuse / message): + - current org-rendered hash = =LINEAR-COMMENT-SHA256= → unchanged → no-op, no API call. + - changed locally, remote unchanged since fetch → =commentUpdate= push. + - both changed (re-fetch the remote comment body; its hash ≠ the stored last-fetched hash) → refuse, report, suggest refresh. +5. On success, update =LINEAR-COMMENT-SHA256= and re-render the comment body from the returned comment. + +** 5. The write path + +=pearl--update-comment-async (comment-id body callback)= over =commentUpdate(id: $id, input: { body: $body }) { success comment { ... } }=, normalizing the returned comment. (*Exact mutation shape to be live-verified during implementation*, the way =commentCreate= and the issue mutations were verified against the real workspace.) + +** 6. Editability highlighting (own = green, others = grey) + +"Comments by other users must not appear editable." The permission gate in step 4.2 enforces the behavior; this section makes it *visible* so a user sees what's editable before trying. + +Each comment heading is colored by editability when the active file is displayed and after every refresh: + +- the viewer's own comments → =pearl-editable-comment= face (green), +- everyone else's, plus bot and external comments → =pearl-readonly-comment= face (greyed, inherits =shadow=, reads as disabled). + +Two custom faces so users can theme them. Because the active file is generated and written to disk, faces can't be stored in the file — they're applied at *display time*. Mechanism (proposed): an overlay pass that runs during render and re-runs on the refresh / find-file hook, reading each comment's =LINEAR-COMMENT-AUTHOR-ID= drawer and comparing it to the cached viewer id. Overlays are preferred over a font-lock matcher because they don't contend with org's own fontification and the highlighted set is small. The viewer id must be resolved before the highlight pass — fetch it alongside the single-issue fetch that already pulls comments, so it's in hand at render. + +** 7. Refresh interaction + +Refreshing the issue (=refresh-current-issue=) replaces the subtree, so an unpushed comment edit would be lost — the same risk description edits already carry. The existing dirty-buffer guard covers it; no new merge logic in v1. + +* Proposed v1 decisions + +1. Only the viewer's own comments are editable. Others' comments (and bot/external comments) refuse with a =user-error=, no network call. +2. Each rendered comment carries a drawer with its id, author id, and last-fetched body hash. +3. Conflict handling is detect / refuse / message — identical to description sync v1. Interactive merge is vNext. +4. Edit-in-place: edit the comment's org body, then run the command from inside the comment subtree (consistent with how descriptions sync). No separate prompt buffer. +5. Comment *deletion* stays out of scope (read / add / edit only). Deletion is its own vNext item if wanted. +6. Editability is shown by color: own comments green, others greyed (decision from the 2026-05-24 review). +7. The edit command is named =pearl-edit-current-comment= and is added to the transient menu under "Issue at point." + +* vNext (out of scope here) + +- Comment deletion. +- Interactive conflict resolution (diff / local-wins / remote-wins) — shared with the description/title conflict vNext. +- Editing via a dedicated prompt buffer instead of in-place. +- Threaded replies (parent comment id). + +* Resolved decisions + +Settled with Craig, 2026-05-24: + +1. *Per-comment drawer* — yes. Each comment heading carries =LINEAR-COMMENT-ID= / =-AUTHOR-ID= / =-SHA256=, consistent with the issue drawer. +2. *Editability visibility* — refuse is enough for behavior, plus color: others' comments render greyed (disabled-looking), the viewer's own render green (see [[*6. Editability highlighting (own = green, others = grey)][Editability highlighting]]). +3. *Command name* — =pearl-edit-current-comment=. +4. *Transient* — yes, add it under "Issue at point" once implemented. diff --git a/docs/specs/issue-conflict-handling-spec.org b/docs/specs/issue-conflict-handling-spec.org new file mode 100644 index 0000000..09acaf6 --- /dev/null +++ b/docs/specs/issue-conflict-handling-spec.org @@ -0,0 +1,74 @@ +#+TITLE: pearl — Interactive Conflict Handling Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*IMPLEMENTED (2026-05-24).* Shipped in two increments: the use-local / use-remote / cancel core, then the smerge rewrite-in-buffer path and the refresh hardening. The open questions are resolved; see "Decisions" at the end. + +Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (description/title editing) and [[file:issue-comment-editing-spec.org][issue-comment-editing-spec.org]] (comment editing). All three share the conflict gate this doc proposes to extend. + +* Problem + +v1's conflict handling is detect / refuse / message: when a description, title, or comment changed both locally and on Linear since the last fetch, the push is refused and a message tells the user to refresh. That protects the remote, but it leaves the user stuck. The only way forward is a manual refresh — which replaces the subtree and *discards the local edit*. So the safe-by-default behavior has a data-loss trap one keystroke away, and no in-Emacs path to actually reconcile the two versions. + +Craig's direction (2026-05-24): keep it simple — offer use-local, use-remote, or rewrite-in-an-Emacs-buffer. Error messages must be descriptive. And there must be a way through that never silently discards the user's input, because that counts as data loss. + +* Current state + +- =pearl--sync-decision= (=pearl.el:~1682=) returns =:noop= / =:push= / =:conflict= from the three-way hash compare (local-rendered vs last-fetched vs current-remote). +- =pearl-sync-current-issue=, =-sync-current-issue-title=, and =pearl-edit-current-comment= all =pcase= on that and, for =:conflict=, just =message= and stop. +- =refresh-current-issue= has a dirty-buffer guard that refuses to refresh when the body has unpushed edits — so a refresh can't clobber silently *today*, but it also can't help resolve; the user has to throw away their edit to move on. + +* Proposed design + +On =:conflict=, instead of only refusing, prompt the user to choose a resolution. One shared helper drives all three call sites (description, title, comment) so the behavior is identical everywhere. + +** The resolution prompt + +=completing-read= (or a transient) with three choices, each with a descriptive label: + +1. *Use local* — push my version, overwriting the remote. Advances the stored hash to the local text. +2. *Use remote* — discard my local edit and take Linear's current version. **Guarded against data loss** (see below): the local text is stashed before it's replaced. +3. *Rewrite in a buffer* — open a reconciliation buffer showing both versions; the user produces the merged text and pushes that. + +A fourth implicit option is always cancel (=C-g=) — leaves everything untouched, same as today's refuse. + +** No data loss — the hard requirement + +"Use remote" and "rewrite" both risk throwing away what the user typed. Before either path replaces the local text, stash it so it's always recoverable: + +- Push the local version onto the =kill-ring= (so =yank= brings it back), and +- write it to a dedicated =*pearl-conflict-backup*= buffer with a heading naming the issue/field and timestamp. + +The stash happens unconditionally on any destructive resolution. The message after "use remote" says where the old text went ("your local version is on the kill-ring and in =*pearl-conflict-backup*="). + +** The rewrite-in-a-buffer flow + +Open a reconciliation buffer prefilled so the user can see and edit both sides. Chosen mechanism (decision 1): *smerge*. Write the two versions as a =<<<<<<< LOCAL / ======= / >>>>>>> REMOTE= conflict and drop the user into =smerge-mode=, so =smerge-keep-current= / =-other= / =-all= and the rest work without custom keys. A short banner names the push/abort keys. (Considered and rejected: a plain two-section buffer — simpler but reinvents conflict navigation; and =ediff= — too heavy for a one-field reconcile.) + +On finish, the reconciled text (markers resolved) is pushed via the same =--update-*= path, and the stored hash advances to it. + +** Descriptive errors + +The conflict prompt and messages name specifics: the field (description / title / comment), the issue identifier, that both sides changed since the last fetch, and the remote's =updatedAt= so the user knows how stale their copy is. No bare "conflict detected". + +* Proposed v1 decisions (this feature) + +1. One shared resolution helper across description, title, and comment. +2. Three resolutions plus cancel: use-local, use-remote, rewrite-in-buffer. +3. Any destructive resolution stashes the local text to the kill-ring *and* a backup buffer first — never discard input. +4. Messages and the prompt are field- and issue-specific. + +* vNext / out of scope + +- Field-level 3-way auto-merge (only the changed lines). +- Conflict resolution for the drawer fields (state/priority/assignee/labels) — those are command-set, not free-text, so they don't have the same merge problem. + +* Decisions (Craig, 2026-05-24) + +1. *Rewrite-buffer mechanism*: =smerge=. Write the two versions as a =<<<<<<< / ======= / >>>>>>>= conflict and drop the user into =smerge-mode=; the =smerge-keep-*= commands work out of the box and the UX matches git muscle memory. No heavy dependency. +2. *Stash location*: kill-ring + a =*pearl-conflict-backup*= buffer. In-memory recovery (yank, or read the named buffer); no file-backup layer in v1. +3. *Default resolution on RET*: cancel. A bare =RET= at the prompt leaves everything untouched, the same as today's refuse — the safest default. +4. *"Use remote" guard scope*: yes. =refresh-current-issue= adopts the same stash-before-replace guarantee, so no refresh path can lose an unpushed edit. (The merge refresh already keeps dirty subtrees rather than overwriting; this hardens the single-issue refresh, which today refuses on a dirty body — it will stash then proceed instead.) diff --git a/docs/specs/issue-query-spec.org b/docs/specs/issue-query-spec.org new file mode 100644 index 0000000..75dcc4b --- /dev/null +++ b/docs/specs/issue-query-spec.org @@ -0,0 +1,258 @@ +#+TITLE: pearl — Issue Query & Saved Reports Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-23 +#+STARTUP: showall + +* Status + +*DRAFT — review incorporated (2026-05-23), awaiting final go-ahead.* Design proposal; nothing in =pearl.el= has changed. The v1 scope is now decided (see [[*Agreed v1 decisions][Agreed v1 decisions]]); deferred items are in [[*vNext][vNext]]. Modifications/rejections of review recommendations are documented in [[*Review dispositions][Review dispositions]]. + +Companion: [[file:issue-representation-spec.org][issue-representation-spec.org]] covers how an issue is *rendered and edited* in org once fetched. This doc covers *which* issues get fetched and *where* they land. They meet at the shared org→Linear write path and the single active-file output model. + +* Problem + +Today the package fetches one thing: issues assigned to me, optionally narrowed to a single project. Everything else — by status, by project regardless of assignee, by project + status, by label, priority, assignee, cycle — has no path, and there's no way to name a query and run it again. + +The ask, verbatim: + +- all open issues assigned to me +- all open issues from a project +- all open issues in a particular status +- all open issues in a particular status in a particular project +- many of these should be saved preferences +- general enough to also cover labels, priorities, etc. + +A follow-up reframed the "saved preferences" half: Linear already has *Custom Views* (saved filters in the UI). Rather than invent a parallel local-only "reports" concept, read the user's existing views, run them from Emacs, and (later) push our own filters up. The answer (verified below) is largely yes. + +So this is one general filter model plus saved entry points on top of it — not a command per ask. + +* Current state (what exists today) + +Grounded in =pearl.el= (line refs drift): + +- *One query shape, hardcoded to "me".* All fetches go through =--get-issues-page-async= (l.368), sending =GetAssignedIssues= against =viewer { assignedIssues(...) }= (l.377-433). No use of the top-level =issues(filter:)= query. +- *The only filter is project* — =filter: { project: { id: { eq: $projectId } } }=. No assignee/state/label/priority/team/cycle filter anywhere. +- *State filtering is client-side and coupled to rendering.* =pearl-issues-state-mapping= (l.94) doubles as a global include-filter: only issues whose state is in the mapping get written (l.106-108). Adding a state mapping silently changes which issues appear. +- *Pagination* is =first: 100= + =after:= cursor, capped at =pearl-max-issue-pages= (l.125, default 10). +- *Output is single-file, single-title.* =--update-org-from-issues= (l.1201) writes =pearl-org-file-path= (l.87) with a hardcoded =#+title: Linear issues assigned to me= (l.1182). The sync hook matches the buffer against the hardcoded regex =linear\.org$= (l.956), independent of the defcustom. +- *Name→ID resolution* exists for teams (=--get-team-id-by-name=, l.745, case-sensitive) and states (=--get-state-id-by-name=, l.709, per-team, case-insensitive). Projects have no name→ID helper. Labels, assignees, cycles: none. +- *Saved filters / reports: none.* + +The fetch layer is the constraint: it can only ask Linear one question. Replacing that one hardcoded query with a general one is the spec's center of gravity. + +* Linear's API: what makes a general model possible + +The top-level =issues(filter: IssueFilter, first:, after:)= query plus the composable =IssueFilter= input type cover every item in the ask: + +| Ask | IssueFilter fragment | +|---------------------------+----------------------------------------------------------| +| assigned to me | =assignee: { isMe: { eq: true } }= | +| assigned to a person | =assignee: { email: { eq: "x@y.com" } }= | +| from a project | =project: { id: { eq: $projectId } }= | +| in a status (by name) | =state: { name: { eq: "In Progress" } }= | +| open (not done/cancelled) | =state: { type: { nin: ["completed", "canceled", "duplicate"] } }= | +| in a team | =team: { key: { eq: "ENG" } }= | +| with a label | =labels: { some: { name: { eq: "bug" } } }= | +| by priority | =priority: { eq: 2 }= (0 none,1 urgent,2 high,3 med,4 low) | +| in a cycle | =cycle: { id: { eq: $cycleId } }= | + +Two facts make "general enough" tractable: =IssueFilter= AND-s sibling fields and composes with =and=/=or= (so "open + status + project" is three sibling fields in one object); and workflow-state =type= (=triage / backlog / unstarted / started / completed / canceled / duplicate=, verified — seven values) is the workspace-independent "open" primitive, where "open" excludes =completed=, =canceled=, and =duplicate=. So one query (=issues(filter:)=) plus a Lisp→=IssueFilter= compiler covers it. + +* Linear Custom Views (verified against the published schema) + +Linear's product "Custom Views" are fully API-accessible. Verified facts (=linear/linear= master GraphQL schema): + +- *Read views:* =customViews(filter: CustomViewFilter, first:, after:, ...)= → =CustomViewConnection=; single via =customView(id)=. Each carries =name=, =description=, =team= (null = workspace-wide), =owner=, =creator=, =shared=, =icon=, =color=. +- *Run a view server-side:* =CustomView.issues(filter: IssueFilter, first:, after:, ...)= resolves the view's own filter on Linear's side. We pass the view id and paginate — no local filter translation. +- *Write views:* =customViewCreate/Update/Delete=; create input requires =name=, optional =filterData: IssueFilter=, =teamId=, etc. (vNext — see decisions.) +- *No "default view" in the API.* "Default" is a UI concept only; a default must be a local preference naming a view. + +*Filter-format asymmetry (the crux).* On *write*, =CustomViewCreateInput.filterData= is typed =IssueFilter= — the same type Layer 1 compiles to. On *read*, =CustomView.filterData= is an opaque =JSONObject!=; the schema does *not* guarantee it round-trips as a re-usable =IssueFilter=. *Conclusion: never re-execute a fetched =filterData= locally.* Use the server-side =CustomView.issues= connection to run a view. (These findings are an implementation prerequisite to re-verify — see below.) + +* Agreed v1 decisions + +Settled in the 2026-05-23 review. These are no longer open. + +1. *Active-file output model.* One configured =pearl-org-file-path= shows *one active view/query at a time*. Running a different saved query or Custom View *replaces* the file contents after dirty-buffer/conflict checks. One Linear issue appears in exactly one place in the active view. (Resolves the output-model question, which gates user-visible multi-query commands — so it's decided up front, not deferred.) +2. *Stable IDs in saved queries.* Saved local queries store stable Linear IDs; human names/keys are display metadata only. Interactive prompts show names; the compiled query executes by ID wherever the API supports it. +3. *=pearl-list-issues= means "my open issues"* — =(:assignee :me :open t)=. No back-compat constraint (no users yet); this is the cleaner default. +4. *Local saved queries are AND-only in v1.* OR is vNext; users needing OR create a Linear Custom View and run it from Emacs. +5. *Sort/order in the query model.* Local saved queries support explicit =:sort= and =:order=, defaulting to =updated= / =desc=. Server-side ordering is limited to Linear's public =orderBy: PaginationOrderBy= — =createdAt= or =updatedAt=, recency-descending, with no direction argument (verified against the schema; the richer per-field =sort: [IssueSortInput!]= arg is marked =[INTERNAL]= and unstable, so v1 avoids it). So =:sort updated= / =:sort created= map to =orderBy= server-side; any other sort field (priority, title, …) or an explicit ascending =:order= is a deterministic client-side sort after fetch (so refresh doesn't reorder headings into noise). +6. *Custom Views are read-only/run-only in v1.* Create/update/delete and pushing local queries up as views are vNext. + +* Implementation prerequisites — schema verification (complete) + +Both the published-schema pass and the live run are done, so this prerequisite is cleared. + +*Published-schema pass* (2026-05-23, against =linear/linear= master =schema.graphql=). Confirmed: + +- =issues(filter:)= takes every planned fragment; =IssueFilter= field/sub-filter names check out — assignee.isMe/email, state.name/type, project.id, team.key, labels.some/every (*no* =none=), priority =NullableNumberComparator= (eq/in/nin), cycle.id, and =and=/=or=. +- Workflow-state =type= values: triage/backlog/unstarted/started/completed/canceled/*duplicate* (seven, not six). +- Issues ordering is =orderBy: PaginationOrderBy= = createdAt/updatedAt only (see decision 5; the per-field =sort= arg is =[INTERNAL]=). +- =CustomView.issues= → =CustomViewConnection= (nodes/pageInfo). =commentCreate(input: CommentCreateInput!)= → =CommentPayload= (comment/success/lastSyncId), input body+issueId(+parentId), all input fields nullable. =Comment.user= is *nullable* (bot/integration comments — see the representation spec). + +*Live run* (2026-05-23, deepsat workspace, via the package's own GraphQL layer with the key from =.authinfo.gpg=). Confirmed: =customViews= returns both shared *and* personal views for the key (6 shared + 1 personal); =issues(filter:)= with =assignee.isMe= + =state.type nin [completed,canceled,duplicate]= + =orderBy: updatedAt= returns the right open issues; =customView.issues= runs a view's filter server-side; comment read works; =commentCreate= on a test issue succeeds (test comment deleted after). The committed fixtures in =tests/testutil-fixtures.el= stay *synthetic* — real workspace data doesn't belong in a public repo — and were confirmed to match the live shapes. =CustomViewCreateInput.filterData= = =IssueFilter= stays unverified-by-use until view-write lands (vNext). + +* Proposed design + +** Layer 1 — the filter DSL (+ validation) + +*Authoring form* (convenient, names allowed; used for ad-hoc filters and hand-written queries). Each key optional; present keys AND-ed: + +#+begin_src elisp +(:assignee :me ; :me | "email@addr" | nil + :open t ; t => state.type nin [completed,canceled,duplicate] + :state "In Progress" ; state name (needs team context) or :state-type + :state-type ("started" "unstarted") ; direct workflow-state type control + :project "Foo" ; project name (needs team context) or id + :team "ENG" ; team key or name + :labels ("bug" "p1") ; label names -> labels.some + :priority high ; symbol (none/urgent/high/medium/low) or 0-4 + :cycle "Cycle 12" ; id, current/upcoming symbol, or team+number/name + :sort updated :order desc) +#+end_src + +*Stored form* (saved queries): the resolved-to-IDs filter plus display metadata plus sort/order. The interactive builder resolves names→IDs at save time; the stored query executes by ID. + +*Selector semantics* (names are ambiguous — projects/labels/states/cycles can collide across teams): + +- =:team= — key or ID; ID internally. +- =:project= — ID, or =(team . name)= for disambiguation. +- =:state= — state type, or state ID/name *with team context*. +- =:labels= — names only when team/project context removes ambiguity; otherwise prompt on multiple matches. +- =:cycle= — ID, =current=/=upcoming= symbols, or team + cycle number/name. +- =:open= and explicit =:state=/=:state-type= — if both set, the explicit state wins (it's more specific). =:open t= ≡ =type nin [completed,canceled,duplicate]= ≡ type in =triage/backlog/unstarted/started=. + +*Validation.* =pearl--validate-issue-filter= runs before compilation: rejects unknown keys, bad priority symbols, incompatible combinations, ambiguous fields lacking team context, empty strings, unsupported value shapes — with clear error messages (tested). A plist silently accepts typos; validation is what makes a user-facing saved-query defcustom safe. + +*Compiler.* =pearl--build-issue-filter (plist)= → the GraphQL =filter:= object, via small pure predicate helpers (=--eq=, =--nin=, =--some=, =--compile-priority=, =--compile-state-filter=), each unit-tested. Adding a dimension is a clause here, not a new command. + +** Layer 2 — general fetch over a normalized pager + +A single =pearl--page-issues= helper accepts a (query-builder . extractor) pair and returns *normalized* issue objects, owning the page cap, vector→list coercion, progress messages, and partial-error behavior in one place. Two callers: + +- =--query-issues-async (filter)= → top-level =issues(filter:)=. +- =--query-view-async (view-id)= → =customView(id) { issues(...) }= (server applies the view's filter). + +The existing assigned-issues fetch becomes the first caller (=filter = {assignee:{isMe:{eq:true}}}=), collapsing the two hardcoded query variants. + +*Error shape.* Internal callbacks distinguish *no results* / *request failed* / *invalid filter* rather than collapsing all to =nil=; user commands collapse them to messages. (V1 minimum: enough to tell an empty result from a failure.) + +** Layer 3 — saved reports (Linear views first, local queries as complement) + +*Read side (main path).* =pearl-run-view= does =completing-read= over the user's =customViews= (cached), then fetches via the view primitive. "Run one of my saved Linear reports from Emacs" with zero local config. + +*Local saved queries (complement).* A defcustom of named filters for ad-hoc / Emacs-only reports, storing IDs + display metadata + sort/order (Agreed decisions 2, 5): + +#+begin_src elisp +(defcustom pearl-saved-queries + '(("My open work" + :filter (:assignee :me :open t) :sort updated :order desc)) + "Named local issue queries. Stored form keeps resolved IDs; +display names are metadata. AND-only in v1; use a Linear Custom +View for OR logic." + ...) +#+end_src + +*Default report.* No API field, so a local =pearl-default-view= names a view (or saved query) run by the bare zero-arg command. (See [[*Review dispositions][Review dispositions]] on why a separate =default-issue-filter= is *not* added.) + +** State mapping vs filter — break the coupling + +Split the two jobs =issues-state-mapping= conflates today: + +- =pearl-state-to-todo-mapping= — render/sync Linear state ↔ org TODO keyword. Rendering only. +- *Query filters* — inclusion/exclusion (=:open=, =:state=, =:state-type=). A filter, not a mapping. + +So adding a state-to-TODO mapping no longer changes which issues appear. + +** Output model — concrete + +One active file (=pearl-org-file-path=). Running a view/query replaces its contents after the dirty-buffer guard (and the representation spec's conflict check). The *file header* records the active source so refresh re-runs it without asking: + +- query/view name, +- run timestamp, +- filter summary, +- issue count, +- truncation warning if the page cap was hit (also =message='d), +- source: local query vs Linear custom view. + +This makes reports self-describing and bug reports legible. The sync hook must recognize the configured =org-file-path=, not just =linear\.org$=. + +** Orientation & refresh commands + +- =pearl-refresh-current-view= — re-run the active source from the header. +- =pearl-refresh-current-issue= — re-fetch the issue at point. +- =pearl-open-current-view-in-linear= — if the source view has a URL. + +** Caching + +Caches for teams/states/projects/labels/views power both filters and interactive completion. V1 cache control: + +- =pearl-clear-cache= command, +- a force-refresh argument on interactive selectors, +- cache keys that include team ID where relevant. + +(Automatic TTL is vNext — see [[*Review dispositions][Review dispositions]].) + +** Layer 4 — commands + +- =pearl-list-issues= — zero-arg "my open issues" (Agreed 3), over Layer 2. +- =pearl-run-view= — =completing-read= over Linear custom views; run server-side. Main saved-report path. +- =pearl-run-saved-query= — pick a local saved query, run it. +- =pearl-list-issues-filtered= — build an ad-hoc filter interactively; *complete from fetched* teams/projects/states/labels/cycles (not free text) to avoid typo'd-filter empty-result confusion; optionally save as a local query. +- =pearl-list-issues-by-project= — keep; reimplement as a thin =(:project X :open t)= call. + +(A transient menu is a separate todo task and the natural front door once these exist.) + +* Phased implementation + +1. *Layer 1 + validation + tests.* Pure =--build-issue-filter= + =--validate-issue-filter= + predicate helpers. Normal/Boundary/Error + pairwise over dimension combinations. No API. Lands green. +2. *Layer 2a — normalized pager + =--query-issues-async=.* Reimplement the assigned-issues fetch over it; characterization test proves =list-issues= still works, then flip it to "my open issues" (Agreed 3). +3. *State-mapping/filter split* + project name→ID helper + =list-issues-filtered= (ad-hoc, complete-from-fetched). +4. *Active-file output model* — header metadata, refresh-current-view, sync-hook recognizes the configured path. (Decided up front, implemented here because everything user-visible depends on it.) +5. *Layer 2b + view read* — =--query-view-async=, =customViews= listing/cache, =run-view=, default-view preference. +6. *Local saved queries* defcustom + =run-saved-query= + =:sort=/=:order=. + +vNext (gated, not in v1): view writes, OR DSL, automatic TTL, per-query/multi-view files. + +* Test strategy + +*Pure unit (first):* valid fragments for assignee me/email, open, state name/type, project ID, team key/ID, labels, priority, cycle; AND composition; =:state= vs =:open= precedence; bad keys / unresolvable names raise clear errors; priority symbol/number normalization; AND-only enforced (OR documented unsupported locally). + +*Query/pagination (request stubs):* top-level =issues(filter:)= uses the compiled variables; pagination follows =hasNextPage=/=endCursor=; page cap reports truncation; a partial error does not masquerade as an empty success; =customView.issues= extracts the same normalized shape. + +*Command/output:* =list-issues= = my open issues; =list-issues-by-project= is a thin general-query caller; running a saved query/view replaces the active file with accurate header metadata; sync hook recognizes the configured =org-file-path=; a dirty active file is not overwritten. + +*Fixtures:* small representative JSON for =issues=, =customViews=, =customView.issues=; normalize vectors / =null= / missing optional fields consistently. + +* Relationship to existing todo.org tasks + +Supersedes / absorbs three open feature tasks once approved — fold them into the phased plan rather than tracking separately: + +- =More issue filters (assignee, label, state, cycle)= — this *is* that task, generalized. +- =Fetch scope beyond assigned issues= — Layer 2 + the =:assignee= dimension. +- =list-issues-by-project= — a thin caller of the general path. + +* Open decisions + +None blocking v1 — the six agreed decisions resolved them. Remaining judgment calls are implementation-level (exact ad-hoc prompt flow, header wording) and don't gate the start. + +* vNext + +- OR support in the local saved-query DSL. +- Interactive sort/order changes (command/menu). +- Sync default sort/order back to Linear Custom Views if the API supports it. +- Create/update/delete Custom Views from Emacs (workspace-mutating; explicit confirmation + shared/personal prompt). +- Optional per-query files or multi-view files — only with demonstrated need *and* a designed duplicate-issue semantics. +- Automatic cache TTL. +- Batch/staged prefetch for interactive prompts (first team choice scopes and fetches the rest) — a perf refinement; v1 can fetch on demand. + +* Review dispositions + +All review recommendations were accepted and incorporated above except the following, modified with reasons: + +1. *Cache TTL defcustom → modified (deferred to vNext).* The review recommended an optional TTL defcustom alongside =clear-cache= and force-refresh. For a single-user tool, an automatic TTL adds invalidation complexity (stale-while-revalidate semantics, per-cache tuning) with little benefit over an explicit force-refresh. V1 ships =clear-cache= + a force-refresh arg on selectors; TTL is listed in vNext if a real need appears. +2. *Separate =pearl-default-issue-filter= defcustom → rejected.* The review floated it ("possibly") as the default for the bare command. With Agreed decision 3 fixing =list-issues= to "my open issues" and =pearl-default-view= covering a user-chosen default report, a third default-filter knob is redundant surface area. The two existing mechanisms cover the need. +3. *Structured error propagation → accepted but scoped.* Adopted as a v1 design principle (distinguish no-results / failure / invalid-filter at the internal boundary), but not a full callback-protocol refactor — v1 implements the minimum needed to keep "empty" and "failed" distinct, leaving a richer error type for later if the command surface grows. + +Everything else — active-file output model, ID-based selectors, state-mapping/filter split, filter validation, sort/order, schema-prerequisite checklist, normalized pager, GraphQL predicate helpers, self-describing headers, refresh/orientation commands, complete-from-fetched prompts, server-side filtering, store-IDs-in-properties, and the full test strategy — was accepted as written. diff --git a/docs/specs/issue-representation-spec.org b/docs/specs/issue-representation-spec.org new file mode 100644 index 0000000..912cee9 --- /dev/null +++ b/docs/specs/issue-representation-spec.org @@ -0,0 +1,230 @@ +#+TITLE: pearl — Issue Org Representation & Editing Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-23 +#+STARTUP: showall + +* Status + +*DRAFT — review incorporated (2026-05-23), awaiting final go-ahead.* Design proposal; nothing in =pearl.el= has changed. v1 scope is decided (see [[*Agreed v1 decisions][Agreed v1 decisions]]); deferred items in [[*vNext][vNext]]; modifications/rejections in [[*Review dispositions][Review dispositions]]. + +Companion to [[file:issue-query-spec.org][issue-query-spec.org]] (which covers *fetching/filtering*). This doc covers how an issue is *rendered and edited* once it's in org. They share the org→Linear write path and the single active-file output model. + +* Problem + +Open a fetched issue in org and the body below the drawer is empty — and with =org-tidy= folding the drawer, the whole entry looks blank. The one piece of free-text the issue has (its description) isn't missing; it's misfiled into a *property*. Users can't tell what they're allowed to edit. + +Grounded in =pearl--format-issue-as-org-entry= (=pearl.el:1114-1173=): the description is written into a =:DESCRIPTION: |= property as 2-space-indented lines (=l.1154-1158=), inside the drawer; the body after =:END:= (=l.1171=) is empty; =org-tidy= then hides the drawer and the entry reads as blank. That's the root of "I opened the task and there's nothing there / I'm not sure what I can edit." + +* Current rendering (what exists today) + +Each issue is a =***= heading =*** = (=l.1146=) plus a drawer carrying =:ID:= (Linear UUID), =:ID-LINEAR:= (the =ENG-123= identifier), =:TEAM:=, =:DESCRIPTION:= (the misfiled body), =:PRIORITY:=, =:LABELS:=, =:PROJECT:=, =:LINK:=, =:PROJECT-ID:= (=l.1148-1171=). Nothing below the drawer. The fetch query pulls =description= (=l.1119=) but *not* comments. State sync resolves team name → ID by network lookup each time (slow, fragile on rename/collision). The renderer strips =[ ]= from titles (=l.1145=) — existing lossy title behavior. + +* Agreed v1 decisions + +Settled in the 2026-05-23 review. + +1. *The org issue body is entirely Linear-owned in v1.* No local-only notes area. The active org file is a synchronized representation of Linear, not a mixed local/remote workspace. +2. *Fetched comments are remote-owned display content.* Users can *add* comments; editing existing comments is vNext (and then only comments authored by the current Linear user, matching Linear's permissions). +3. *New entries use only namespaced =LINEAR-*= properties.* No compatibility layer for the old =:ID:= / =:ID-LINEAR:= shape (no users yet). +4. *Description sync starts as an explicit command only.* Automatic save-triggered description sync is vNext, after no-op detection and conflict handling are proven. +5. *V1 conflict handling is detect / refuse / message.* Interactive diff-merge or local/remote choice is vNext. + +* Content ownership and refresh semantics + +The hard part isn't moving the description — it's distinguishing machine-owned fetched content from user edits once refresh, comments, and sync coexist. v1 makes this simple by fiat (decision 1: the whole body is Linear-owned), but the layout and refresh model still have to be explicit. + +** Generated entry layout + +#+begin_src org +*** TODO [#B] ENG-123 Title +:PROPERTIES: +:LINEAR-ID: <uuid> +:LINEAR-IDENTIFIER: ENG-123 +:LINEAR-URL: https://linear.app/.../ENG-123 +:LINEAR-TEAM-ID: <id> +:LINEAR-TEAM-NAME: ENG +:LINEAR-PROJECT-ID: <id> +:LINEAR-PROJECT-NAME: Foo +:LINEAR-STATE-ID: <id> +:LINEAR-STATE-NAME: In Progress +:LINEAR-ASSIGNEE-ID: <id> +:LINEAR-ASSIGNEE-NAME: Craig +:LINEAR-LABELS: [bug, p1] +:LINEAR-DESC-SHA256: <hash of last-fetched markdown> +:LINEAR-DESC-UPDATED-AT: <remote timestamp> +:END: + +Description text managed by Linear (org-rendered). + +**** Comments +***** <author> — <timestamp> +comment body +#+end_src + +*Store IDs and display names separately* for team, project, assignee, state, labels (and later cycle). Commands display names; they mutate by ID. This kills the per-render network name-lookup. + +*Provenance for the description* lives as a *hash + remote timestamp* in properties — not the full raw markdown. A large multiline markdown property is awkward in org and bad with folding. When the sync/no-op check needs the exact last-fetched markdown, fetch current remote markdown before deciding, or keep it in an internal cache keyed by =LINEAR-ID=. (See [[*Conflict handling][Conflict handling]].) + +** Refresh model — merge by ID, reconciled with the active-file output model + +The query spec's output model says *switching to a different view/query replaces the active file*. This spec's refresh says *don't wholesale-rewrite*. Both hold, for different actions: + +- *Switching source* (run a different view/query) → the issue set changes; replace the file contents after the dirty-buffer + conflict checks. One issue appears in one place. +- *Refreshing the same source* (=refresh-current-view=, =refresh-current-issue=) → *merge by =LINEAR-ID===: update each existing issue subtree in place, add new matches, drop issues no longer in the result. Per subtree, run the conflict check before overwriting a description that was edited locally but not yet pushed. + +A wholesale rewrite on same-source refresh would clobber un-pushed description edits; merge-by-ID + per-subtree conflict check is what protects them. + +* Proposed model — body is editable content, drawer is machine-managed metadata + +Organizing principle: the body holds what a human reads and writes (description, comments); the drawer holds structured fields commands manage. An =org-tidy= user edits body text + runs commands and never touches the drawer. + +** Description → body + +Render the description as the heading body (org-converted — see [[*Markdown vs org — the conversion question][conversion]]). Opening a task now shows its description; the org-tidy blank-entry problem disappears. The body is the editable region; an explicit command (decision 4) pushes edits back, behind the conflict gate. + +** Drawer = command-managed fields + +State (TODO keyword), priority, labels, project, assignee live in the drawer/heading and change via dedicated commands ("Set assignee, priority, labels" task), which resolve names→IDs. =org-tidy= users never need to open the drawer. + +** Comments as a body subtree + +Fetch comments (needs a query change — not pulled today) and render *oldest-first* as =****= → =*****= sub-headings (=<author> — <timestamp>=, body beneath), so the thread reads chronologically and "add comment" appends at the end. =pearl-add-comment= creates a new comment via =commentCreate= and inserts/refreshes the returned comment. Fetched comments are remote-owned (decision 2): editing an existing comment heading does *not* sync back in v1. + +*Comment shape (verified against the published schema).* =Issue.comments= → =CommentConnection= (nodes/pageInfo); each =Comment= has =body= (markdown — runs through the same conversion tier as the description), =createdAt=, and =user=. *=user= is nullable* — comments from integrations or bots have no user, carrying =botActor= / =externalUser= instead. The renderer must fall back to the bot/external actor name (or a literal like "(automation)") for the author rather than assuming a =user.name=. =commentCreate(input: CommentCreateInput!)= returns =CommentPayload= (=comment=, =success=); the input takes =body= + =issueId= (and optional =parentId=), with success checked the same way as issue creation before reporting. + +** Affordance + discoverable commands + +A one-line preamble note (body = description, edit + sync via command; Comments subtree = thread, add via command; fields = drawer, change via commands). But commands matter more than a note — expose discoverable ones that work from *anywhere inside an issue subtree*: + +=pearl-sync-current-issue=, =pearl-open-current-issue=, =pearl-add-comment=, =pearl-set-priority=, =pearl-set-assignee=, =pearl-refresh-current-issue=. + +** Sub-issues (later) + +Optional nested headings; out of scope for v1. + +* Markdown vs org — the conversion question + +Linear stores descriptions/comments as *markdown*; we want *org* in the body. The directions differ in difficulty. + +- *org → markdown (push):* =ox-md= is built in, but it is *not* round-trip-faithful for the subset Linear uses (see [[*ox-md rejected for push][ox-md rejected for push]]). Push is therefore a hand-rolled inverse of the fetch converter. +- *markdown → org (fetch):* no built-in. The only place pandoc is tempting. + +** ox-md rejected for push + +The original recommendation was =org-export-string-as ... 'md= for the push direction. Empirical testing (2026-05-23) of =org→md(md→org(x))= over the conversion matrix showed *zero of nine samples round-trip cleanly*. =ox-md= injects a =# Table of Contents= header, inverts emphasis (org =*italic*= → md =**bold**=), *drops checkbox markers* (=- [x] done= → =- done=), converts fenced code to 4-space indented blocks (losing the language), and reindents lists. + +This breaks the conflict gate two ways: the no-op guard compares =hash(org→md(body))= against the stored =LINEAR-DESC-SHA256= (hash of the last-fetched markdown), so a lossy push makes *every* no-op sync look like an edit; and the lossy output would *corrupt content pushed back to Linear* (dropped checkboxes, lost code-fence languages). This is the same lossy-round-trip failure mode the spec already rejected pandoc for — it applies to =ox-md= too. + +The push converter (=pearl--org-to-md=) is therefore hand-rolled as the symmetric inverse of the fetch converter (=pearl--md-to-org=), which makes round-trips byte-stable for the supported subset. Owning both directions also keeps the conversion tier self-consistent. *Two documented lossy edges remain* (inherent to the fetch converter, not the push side): a markdown =# heading= renders to a bold line on fetch and stays a bold line on push (restoring =#= would fork the org outline); single-asterisk markdown italics are unsupported on fetch (only =_underscore_= italics convert). + +** Pandoc — pros/cons + +- *Pros:* full-fidelity bidirectional GFM↔org; one tool; battle-tested. +- *Cons:* hard external-binary dependency (MELPA-hostile; users without it get broken sync); subprocess per conversion; *lossy round-trip* (pandoc reflows/normalizes → spurious diffs on no-op fetch/push); cross-platform/version drift. + +** Recommendation — pure-elisp default, pandoc optional + +Hand-roll *both* directions: push via =pearl--org-to-md= (the inverse of the fetch pass — see [[*ox-md rejected for push][ox-md rejected for push]]), fetch via the lightweight md→org pass. No dependency, byte-stable round-trips. Pandoc is an *optional* enhancement: if =(executable-find "pandoc")= and a defcustom opts in, route both directions through it. Detected, never required — MELPA-safe. + +** Conversion matrix (the testable contract) + +"High-frequency constructs" needs a precise, testable subset. Unsupported constructs are *preserved as literal text*, never emitted as malformed org. + +| Markdown | Org | Note | +|----------+-----+------| +| =**bold**= | =*bold*= | | +| =*italic*= / =_italic_= | =/italic/= | underscores in identifiers must not trigger emphasis | +| =`code`= | =~code~= | | +| =```lang ... ```= | =#+begin_src lang ... #+end_src= | language preserved | +| =- item= / =* item= | =- item= | | +| =1. item= | =1. item= | | +| =- [ ]= / =- [x]= | =- [ ]= / =- [X]= | checkboxes | +| =[text](url)= | =[[url][text]]= | | +| => quote= | =#+begin_quote ... #+end_quote= | | +| =# Heading= | *bold line*, NOT an org heading | an org heading would fork the issue subtree and corrupt structure | +| tables / HTML / footnotes | literal pass-through | preserved, not converted | + +The =# Heading= → bold-line rule is load-bearing: converting a markdown heading inside a description to a real org heading would split the issue's subtree. + +* Conflict handling + +The round-trip-drift guard is necessary but not sufficient — it prevents no-op churn; it doesn't define conflicts. Promote it from a note to a *phase gate on sync-back*. The sync command compares three things: + +- *last-fetched* Linear markdown (hash in =:LINEAR-DESC-SHA256:=), +- *current org-rendered* markdown (re-render the body to md, hash it), +- *current remote* markdown / =updatedAt= (fetch before pushing). + +Outcomes: + +- org == last-fetched → no local edit → *no API call* (no-op guard). +- org changed, remote == last-fetched → clean push. +- org changed *and* remote changed since last fetch → *conflict*: stop, refuse to push, message the user (decision 5). Resolution workflows (diff/merge, local/remote-wins) are vNext. + +* Parsing — org-element, not regex + +Current parsing assumes a level-3 heading with a drawer immediately after and walks lines/regex. Once bodies and comment subtrees exist, that's brittle (misread drawers, nested comment headings mistaken for issues). Spec an =org-element=-based parser: locate issue headings by the durable =:LINEAR-ID:= property, read properties via org APIs, treat depth structurally — never =^\*\*\*= regexes. + +* Internal representation + +Normalize API responses into internal plists/structs *before* rendering, so the renderer never sees whether Linear returned a vector, =null=, or an omitted field. Comments, assignees, cycles, and views multiply the missing/null/vector handling otherwise. Model boundaries (filter compilation, API transport, issue/comment models, org rendering, org parsing, sync orchestration, commands) stay as *logical* sections — see [[*Review dispositions][Review dispositions]] on keeping a single file. + +* Actions a user wants in the body space + +- *Edit the description* in place → explicit sync (push, behind the conflict gate). +- *Read the comment thread* without leaving Emacs. +- *Add a comment* → =commentCreate= (append a sub-heading). +- (later) navigate sub-issues. + +Field changes (assignee/priority/labels/state) stay command-driven, not body edits. + +* Impact on existing todo.org tasks + +Gives concrete shape to three already-open feature tasks; implement them together: + +- =Sync title and description back to Linear= — description-in-body + explicit push. *Phase title sync separately* from description (its own last-fetched-title hash + conflict behavior; note the existing bracket-stripping lossiness). Keep TODO-keyword state sync as the only automatic heading mutation in the first body-editing phase. +- =Add a comment to an issue from Emacs= — the comment subtree + =commentCreate=. +- =Set assignee, priority, and labels from Emacs= — command-driven drawer fields (mutate by ID). + +Cross-cuts the query spec at the shared write path and the active-file/refresh model. + +* Phased implementation + +1. *Description → body (read-only) + namespaced properties.* Move description out of =:DESCRIPTION:= into the body; switch to =LINEAR-*= properties storing IDs + display names; provenance hash + timestamp. Characterization test of the old shape first, then the new render; confirm =org-tidy= no longer shows a blank entry. +2. *org-element parser.* Locate by =:LINEAR-ID:=, structural depth; replaces regex parsing before subtrees land. +3. *Conversion tier.* Hand-rolled org→md push (inverse of the fetch pass; =ox-md= rejected for lossy round-trips) + lightweight md→org fetch per the matrix; unit-test the matrix (Normal/Boundary/Error) and the no-op round-trip invariant. +4. *Refresh = merge by ID* + per-subtree conflict check; reconcile with the active-file replace-on-switch model. +5. *Description sync-back (explicit command)* behind the conflict gate (the round-trip guard is the phase gate). Title sync as a separate step. +6. *Comments* — add to the fetch query; render oldest-first; =add-comment= via =commentCreate=. +7. *Pandoc optional path* + the affordance line + discoverable commands. +8. *(later)* sub-issues, comment editing, local notes, save-hook automation, interactive conflict resolution. + +* Test strategy + +*Characterization (before changing rendering):* old shape renders description in =:DESCRIPTION:= with empty body; dirty visiting buffer not overwritten; state sync uses only matching issue headings; current parser behavior with drawer placement. + +*Per phase:* description after =:END:= with no =:DESCRIPTION:= property; org-element parser extracts properties even with body text + comment subtrees; comments render with IDs/timestamps oldest-first; =add-comment= makes one mutation and inserts/refreshes the returned comment; no-op description sync makes *no* API call; local-edit + remote-unchanged pushes the expected markdown; local-edit + remote-changed refuses with a conflict message; unsupported markdown stays readable and doesn't corrupt org. + +*Golden rendering:* small, intentional string snapshots of representative issue entries. + +* Open decisions + +None blocking v1 — the five agreed decisions resolved the ownership, conflict, sync-trigger, comment-immutability, and property-naming questions. Remaining calls are implementation-level (exact converter edge handling, command key bindings). + +* vNext + +- Local-only notes under issues, if a clean ownership representation emerges. +- Editing existing comments — only those authored by the current Linear user. +- Automatic description sync on save (after no-op detection + conflict handling are proven). +- Interactive conflict handling: diff/merge, local-wins, remote-wins, manual merge. +- Read-only text properties on remote-owned regions (after the command UX exists). +- Sub-issue rendering. + +* Review dispositions + +All review recommendations were accepted and incorporated above except the following, modified with reasons: + +1. *"Split representation from network/API code" into modules → modified.* Adopted the *logical* boundaries (filter compilation / transport / models / rendering / parsing / sync / commands) and the "normalize before rendering" discipline, but *kept a single file* for v1. The package is a single-file =pearl.el= aiming at MELPA, where single-file is a virtue; splitting into multiple files is a larger restructuring with its own review. Logical sections + pure helpers get the unit-testability the review wants without the file split. Revisit multi-file only if size forces it. + +2. *Read-only text properties on remote-owned regions → deferred (the review's own lighter recommendation).* v1 detects edits to remote-owned generated areas and warns/refuses to push rather than making regions buffer-read-only, which would frustrate org users and complicate tests. Hard read-only is in vNext. + +Everything else — Linear-owned body, namespaced =LINEAR-*= properties, IDs-with-display-names, hash+timestamp provenance (not raw-markdown-in-property), merge-by-ID refresh reconciled with active-file replace, conflict detect/refuse/message as a phase gate, explicit-command sync, separate title/description sync, org-element parsing, the conversion matrix, oldest-first read/add-only comments, normalized model objects, discoverable subtree commands, and the full test strategy — was accepted as written. diff --git a/docs/specs/issue-sort-order-spec.org b/docs/specs/issue-sort-order-spec.org new file mode 100644 index 0000000..478b074 --- /dev/null +++ b/docs/specs/issue-sort-order-spec.org @@ -0,0 +1,131 @@ +#+TITLE: pearl — Interactive Sort/Order Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*Reviews incorporated through round 2; rubric =Ready* (Craig, 2026-05-25).* The first draft's "reparse the issue subtrees and rewrite" re-sort would have discarded unsaved edits; this revision makes client-side sort *move whole subtrees by =LINEAR-ID= byte-for-byte*, makes header persistence atomic with the reorder, and pins down Custom View behavior. The three caveats (Custom View refuse-server-sort in v1, toggle default =updated desc=, title-sort uses the visible heading) are *adopted as final v1 decisions*. Modified recommendations are in Review Dispositions. + +Companion to [[file:issue-query-spec.org][issue-query-spec.org]], which defines the =:sort= / =:order= the saved-query layer already supports. This doc covers changing the order of the *current* view interactively, without hand-editing a saved query. + +* Problem + +v1 supports =:sort= (=updated= / =priority= / =title=) and =:order= (=asc= / =desc=) on saved queries. But to change how the active file is ordered, the user has to edit =pearl-saved-queries= (or the source plist) by hand and re-run. There's no "sort this view by priority, descending" command for a view you're actually looking at. + +* Current state + +- =pearl--sort-issues= applies =:sort= / =:order= client-side at the render boundary, so a refresh always lays headings out the same way. +- =:sort= = =priority= / =title= are client-side; =created= / =updated= map to the server =orderBy= (the only fields Linear's API orders on). The saved-query layer stores =:sort= / =:order= as *symbols*. +- The active file's =#+LINEAR-SOURCE:= header records the source plist; =pearl--update-source-header= currently rewrites the count/timestamp fields but intentionally leaves the rest untouched, so there's no header-replace helper yet. +- The merge-refresh path deliberately protects dirty subtrees (never overwrites a subtree with unpushed edits) — the same data-loss concern applies to any in-place reorder. +- *Custom Views:* the =customView(id){ issues }= query carries *no* =orderBy= argument, and =pearl--query-view-async= has no order parameter. A view's order is whatever the server returns for that view. + +* Proposed design + +** The commands + +=pearl-set-sort= (interactive, in the active file): =completing-read= the sort key (=updated= / =created= / =priority= / =title=), then the order (=asc= / =desc=); update the recorded source; apply the new order. =pearl-toggle-sort-order= flips =asc=/=desc= on the current sort and re-applies. (Transient placement deferred — a plain =M-x= is enough for v1; the menu keys are unsettled, tracked under the transient-review task.) + +** Completion and the symbol contract (MP4) + +Completion *displays* strings but the source stores *symbols*: =updated= / =created= / =priority= / =title= and =asc= / =desc=. Interactive input is coerced string -> symbol before it touches the source. An unknown =:sort= / =:order= already in a source produces a =user-error= rather than silently mis-sorting, so the header stays compatible with existing =:sort priority :order asc= data. + +** Sort-key sources — sort what you're looking at (MP1) + +Client-side keys read the *current local buffer state*, not a refetch: + +- =priority= from the heading's priority cookie / =LINEAR-PRIORITY= drawer as currently displayed; +- =title= from the current heading title, stripped of TODO keyword / priority cookie / tags / identifier the same way title sync strips it (so an unsaved local title edit sorts by what you see). + +** Re-order in place: move whole subtrees, never reconstruct (HP1) + +Client-side sort (=priority=, =title=) *moves existing issue subtrees*; it never re-renders them from parsed data (that path, via =pearl--format-issue-as-org-entry=, would discard unsaved description/comment edits and normalize user text). The command: + +1. identifies the top-level issue subtrees (each with a =LINEAR-ID=) under the single parent heading; +2. computes each one's sort key from its current org data (above); +3. reorders the subtree *regions* and rewrites them *byte-for-byte* — descriptions, comments, provenance drawers, and any local edits survive unchanged; +4. leaves a non-issue / malformed subtree (no =LINEAR-ID=) in a defined position (sorted last, stable) rather than dropping it. + +This works on a dirty buffer precisely because it preserves text. A test edits a description and a comment locally, sorts, and asserts the exact edited text is intact afterward. + +** Server-side sort and Custom Views (HP2) + +Server-side keys (=created=, =updated=) come from the server =orderBy=, and the fetch may have been truncated at the page cap, so the correct order needs a refetch with the new =orderBy=: + +- *Filter / saved-query / my-open sources:* re-run the source through the refresh path with the updated =orderBy= (=pearl--query-issues-async= already accepts =order-by=). +- *Custom View sources:* =customView.issues= has no verified =orderBy= in the API or the code, so v1 *refuses* a =created=/=updated= sort on a Custom View with a clear message ("Linear does not support server-side ordering for Custom Views; sort by priority or title to reorder the fetched issues"). Client-side keys (=priority=/=title=) still work on a view — they reorder the fetched (possibly truncated) page in place, the same subtree-move as any other source. (Verifying and adding =customView.issues(orderBy:)= is a vNext research item.) + +** Atomic header persistence (HP3) + +=#+LINEAR-SOURCE= is updated *only after* the reorder actually succeeds, so the header never claims an order the buffer doesn't show (which a later =refresh-current-view= would otherwise reproduce): + +- client-side: write the header only after the subtree move completes; +- server-side: pass the updated source into the fetch; write the header only as part of a successful render/merge; +- on a failed refetch or a dirty-buffer refusal: leave the old header unchanged and message that the sort was not applied. + +This needs a small helper to replace the =:sort=/=:order= in the =#+LINEAR-SOURCE= line (=pearl--source-with-sort= builds the updated plist; a header-replace helper writes it), distinct from today's count/timestamp-only =pearl--update-source-header=. + +** Toggle with no current sort (MP2) + +=pearl-toggle-sort-order= on a source with no =:sort=/=:order= defaults to =updated desc=, then toggles to =updated asc= and back. (Confirm the default.) + +** Outcome messages (UX) + +The command names exactly what it did: "Sorted current buffer by priority ascending" (client-side), "Refetched My open issues ordered by updated descending" (server-side), "Could not sort: this file has no LINEAR-SOURCE header", "Could not sort a Custom View by created; Linear has no server-side ordering for views". + +* Proposed v1 decisions (this feature) + +1. =pearl-set-sort= + =pearl-toggle-sort-order= (transient placement deferred). +2. Completion displays strings, the source stores symbols; an unknown source value is a =user-error= (MP4). +3. Client-side sort moves whole issue subtrees by =LINEAR-ID=, byte-for-byte, preserving local edits — never reconstructs from parsed data (HP1). +4. Client-side keys read current buffer state (priority cookie/drawer, stripped heading title) (MP1). +5. Server-side keys refetch with the new =orderBy= for filter/saved-query/my-open sources; Custom Views *refuse* server-side sort in v1 but allow client-side reorder of the fetched page (HP2). +6. =#+LINEAR-SOURCE= is written only after a successful reorder/refetch; failure leaves it unchanged (HP3). +7. =toggle-sort-order= with no sort defaults to =updated desc= (MP2). +8. Active-header only — no write-back to =pearl-saved-queries= in v1 (MP3). + +* Resolved decisions + +The three caveats are final: + +1. *Custom View server sort* (decision 5): refuse =created=/=updated= on a view in v1; client-side keys still reorder the fetched page. Verifying =customView.issues(orderBy:)= is vNext. +2. *Toggle default* (decision 7): =updated desc= when no sort is set. +3. Client-side =title= sort uses the visible heading title, including an unsaved local edit ("sort what I see"). + +* Files touched + +- =pearl.el=: =pearl-set-sort= + =pearl-toggle-sort-order=; pure helpers =pearl--source-with-sort= (updated source plist) and a sort/order symbol validator/coercer; a =#+LINEAR-SOURCE= replace helper; a client-side subtree-region-move helper (keyed by =LINEAR-ID=, computing keys from buffer state); the server-side re-run wired through the refresh path with the Custom View refusal. +- =docs/=: this spec. +- =README.org=: a short "sorting the current view" note. + +* Test plan + +- =pearl--source-with-sort= updates / normalizes =:sort= and =:order= (symbols); an unknown value errors. +- Missing =#+LINEAR-SOURCE= refuses with the named message. +- Client-side priority/title sort moves whole subtrees and *preserves unsaved description and comment edits* (assert exact edited text after sorting). +- Client-side sort updates =#+LINEAR-SOURCE= only after the move succeeds. +- Server-side filter source passes the right =orderBy= + updated source to the refresh/render path. +- Server-side fetch failure leaves the source header unchanged and messages "not applied". +- Custom View + =created=/=updated= refuses with the view message; Custom View + =priority=/=title= reorders the fetched page in place. +- =toggle-sort-order= with no sort/order applies the =updated desc= default. +- Active-header sort change does not persist into =pearl-saved-queries=. +- Malformed / non-issue subtree sorts last, stable, not dropped. + +* Review Dispositions + +*Round 1 (Codex, 2026-05-25).* Rubric =Not ready=. Accepted as written: HP1 (move whole subtrees byte-for-byte rather than reparse/rewrite — the core safety fix), HP3 (atomic header persistence with rollback on failure), MP1 (client-side keys read current buffer state), MP2 (toggle default =updated desc=), MP3 (active-header only, no saved-query write-back in v1), MP4 (string display / symbol storage, unknown -> user-error), and the UX outcome messages, architecture helper shape, and expanded test plan. One was modified: + +- *HP2 (Custom View server sort) — modified to a decision.* The reviewer offered "verify and implement =customView.issues(orderBy:)=" or "refuse server-side sort on views". Chose refuse-in-v1: =customView.issues= has no verified =orderBy= in the API or the code, and a view already carries its own server-defined order, so adding an unverified ordering argument is out of proportion to a v1 sort command. Client-side keys still reorder the fetched page on a view. Verifying =customView.issues(orderBy:)= is recorded as a vNext research item. + +Everything else accepted as written. + +*Round 2 (Codex, 2026-05-25).* Rubric =Ready with caveats=; no new findings — MP1-MP3 were each "confirm decision X" (Custom View refuse-server-sort, toggle default =updated desc=, title-sort-uses-visible-heading), plus a test nudge (a malformed non-issue subtree fixture, already in the test plan). Resolved by adopting all three as final v1 decisions (latitude granted by Craig). Rubric -> =Ready=. + +* vNext / out of scope + +- Verify + implement =customView.issues(orderBy:)= for server-side sorting of Custom Views. +- Saved-query write-back for sort/order (a separate "save this ordering to the query" step). +- A dedicated sort transient with one-key sort choices. +- Multi-key sort (priority then updated). +- Manual per-heading reordering that survives refresh. diff --git a/docs/specs/issue-sources-spec.org b/docs/specs/issue-sources-spec.org new file mode 100644 index 0000000..a4374c3 --- /dev/null +++ b/docs/specs/issue-sources-spec.org @@ -0,0 +1,144 @@ +#+TITLE: pearl — Issue Sources: Favorites, Views, and Filters Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-27 +#+STARTUP: showall + +* Status + +*Review incorporated (Codex, 2026-05-27); rubric → Ready.* The favorites schema is verified against the live API and folded in (resolving the one blocking finding), all five high-priority findings are dispositioned, and the Issue / Label / User identity questions are decided. Near-zero engine work remains: two small filter-compiler forms (=:label-id=, =:assignee-id=) for rename-proof favorite resolution; everything else is command surface over the existing async query/render path. Modified and rejected review points are in *Review dispositions* at the bottom. + +* Problem + +Pearl's fetch surface is fragmented and me-centric, and it ignores the curation the user has already done inside Linear. + +- =pearl-list-issues= is "my open issues." =pearl-list-issues-by-project= sounds like "the project's issues" but is hardcoded to =:assignee :me=, so it silently shows only the viewer's issues in the project, never the whole project. A command named for a non-assignee dimension that injects assignee=me is misleading (same class as other "looked functional, surprised the user" defects). +- There is no way to see all open issues in a project, or another person's issues, without hand-writing a local saved query or walking the five-prompt ad-hoc builder, and the builder's assignee prompt only offers "me / any", so a *specific other person* is not reachable through it at all. +- The user curates rich filters in Linear's UI as Custom Views and stars them (and projects, labels, cycles, people) as *favorites*. Pearl can run a Custom View by id but cannot read the viewer's favorites, so the curation already done in the far better filter UI is invisible to Pearl. The goal: build the common views once in Linear, favorite them, and have Pearl mirror them, which can remove most of the need for ad-hoc "by X" fetches. +- Local =pearl-saved-queries= solve the "reuse a filter" need but require editing Lisp/Customize and overlap heavily with favorited Custom Views. + +* Current state + +- =pearl-list-issues [project-id]= builds =(:assignee :me :open t [:project P])=. Me-locked even with a project. +- =pearl-list-issues-by-project= picks a team then a project and calls =pearl-list-issues=; inherits the me-lock. +- =pearl-list-issues-filtered= / =pearl--read-filter-interactively= is the ad-hoc builder: team, open?, state, project, labels, assignee (me / any only). Offers to save the result as a local saved query. +- =pearl-run-saved-query= runs a named entry from =pearl-saved-queries= (local Lisp, persisted via Customize). +- =pearl-run-view= runs a Linear Custom View by id (server applies the view's filter). +- =pearl--build-issue-filter= compiles =:assignee :me= (isMe), =:assignee "<email>"=, =:project=, =:team=, =:labels= (by name), =:state=, =:state-type=, =:priority=, =:cycle=. It does *not* yet compile label-id or assignee-id forms (added by this spec, see below). +- *Source model (corrected per HP4).* A rendered file's =#+LINEAR-SOURCE= holds one of exactly two persisted kinds: =:type filter= or =:type view=. =pearl-run-saved-query= renders as a =:type filter= source with the query's name (and =:sort=/=:order=) copied into the header. =pearl-refresh-current-view= accepts only =filter= and =view=. *No =:type saved-query= or =:type favorite= exists, and none is added* — refresh must depend on the stable source descriptor in the file, never on the favorites list or the saved-query store. +- *Favorites: not integrated.* No query of the viewer's favorites. + +* Proposed design + +The unifying idea: everything Pearl fetches is a *source*. Favorites are the curated front door; the per-dimension filters are the primitives favorites resolve into; the ad-hoc builder is the exploratory fallback. A favorite is a *picker entry*, never a persisted source — when chosen it resolves to a concrete =:type filter= or =:type view= source before rendering, so refresh stays stable. + +** Verified favorites schema (2026-05-27, live API) + +Each =Favorite= node carries =type= (the dispatch key), =title= (a ready display label, so the picker needs no per-entity lookup for its text), =url= (the browser fallback), =sortOrder= (Linear's favorite ordering), and a typed entity reference. The query Pearl issues (ids only — see HP3 decision: resolve by stable id, not name/email): + +: query { favorites { nodes { id type title url sortOrder +: customView { id } project { id } cycle { id } +: label { id } user { id } issue { id identifier } +: team { id } projectLabel { id } document { id } dashboard { id } } +: pageInfo { hasNextPage endCursor } } } + +Introspection of the =Favorite= type confirmed the favoritable entity fields: =issue=, =project=, =cycle=, =customView=, =label=, =projectLabel=, =user=, =team=, =document=, =initiative=, =dashboard=, =pullRequest=, =release=, =releaseNote=, =facet=, =predefinedViewType=, =projectTab=, =customer=, plus convenience fields =title=, =url=, =detail=, =color=, =icon=, =folderName=, =owner=, =sortOrder=. Craig's current 9 favorites span customView / project / issue; the schema supports the rest. + +Pagination: the =favorites= connection is paginated like other Linear connections. Page it for correctness (reuse the issue pager's shape). Low-risk in practice — favorites are typically a single page — but a partial list must not look complete. + +** Favorite resolution table + +=pearl--normalize-favorite= turns each node into =(:kind KIND :title T :url U :sort-order N :id ID [:identifier IDENT])=. =pearl--favorite->source= then resolves it: + +| favorite =type= | required API fields | v1 action | persisted source | +|----------------------+------------------------+--------------------+----------------------------------------------------| +| =customView= | =customView.id=, title | run the view | =(:type view :name T :id ID :url URL)= | +| =project= | =project.id=, title | filter | =(:type filter :name T :filter (:project ID :open t))= | +| =cycle= | =cycle.id=, title | filter | =(:type filter :name T :filter (:cycle ID :open t))= | +| =label= | =label.id=, title | filter | =(:type filter :name T :filter (:label-id ID :open t))= | +| =user= | =user.id=, title | filter | =(:type filter :name T :filter (:assignee-id ID :open t))= | +| =issue= | =issue.identifier=, url | browser-only (HP2) | none — opens =url=, renders no source | +| anything else | =url=, title | browser-only | none — opens =url= | + +Refresh: the persisted =filter= / =view= source re-runs its concrete query; refresh never re-reads the favorites list. Issue and non-list favorites persist nothing (they only open a URL), so there is nothing to refresh. + +The two filter forms the compiler must grow (HP3): =:label-id ID= → =labels.some.id.in [ID]=, =:assignee-id ID= → =assignee.id.eq ID=. The existing name/email forms (=:labels=, =:assignee "<email>"=) stay for the ad-hoc builder; favorites use the rename-proof id forms. + +** Unified source picker (the everyday entry point) + +=pearl-pick-source= fetches the viewer's favorites and lists them together with the local saved queries in one =completing-read=, each candidate an attached =(display . plist)= pair so dispatch reads the plist rather than parsing the display string: + +: [view] Active sprint bugs +: [project] Orchestration Dashboard +: [user] Vrezh Mikayelyan +: [label] security +: [saved] My open work + +Picker rules (HP5): + +- *Ordering:* favorites first in Linear's =sortOrder=, then local saved queries alphabetically. +- *Display:* =[type] Title=. Duplicates are disambiguated by the =[type]= tag; only a residual exact collision appends a short id/url suffix, so labels stay clean. +- *Favorites-fetch failure ≠ empty:* on a request/GraphQL error, message the failure and fall back to listing saved queries; never let a failed fetch masquerade as "no favorites". +- *No runnable sources at all:* a clear =user-error= naming the setup ("No favorites or saved queries — star views in Linear, or set =pearl-saved-queries="). + +Selecting a list-capable entry resolves it to a source and renders into the active file through the existing query/render path. Selecting an Issue or non-list favorite calls =pearl--open-favorite-url= (which refuses with a clear message if the URL is nil rather than calling =browse-url= on nil) and messages "Opened <type> favorite in browser: <title>". =pearl-run-view= and =pearl-run-saved-query= remain as direct commands; =pick-source= is the discoverable superset. + +** The by-X dimensions as primitives + +The favorite dispatch and the builder resolve into the same per-dimension filters. Two pieces: + +- *Fix by-project*: drop the =:assignee :me= lock so a project fetch means the whole project, and rename the source from "My open issues in project" to "Project issues: <name>". =pearl-list-issues-by-project= stays as a thin command and as the Project-favorite dispatch target. "My issues in a project" is a two-dimension pin (project + me) and lives in the builder — =pearl-list-issues= (l) already owns the me-slice. +- *Assignee / label by stable id*: the favorite path resolves users and labels by id (above). No new standalone by-assignee / by-label command — a person or label worth re-fetching gets favorited; a one-off goes through the builder. + +We do *not* add standalone =by-label= / =by-cycle= / =by-team= / =by-state= / =by-priority= commands in v1. + +** Ad-hoc builder enhancement (the exploratory fallback) + +=pearl--read-filter-interactively='s assignee prompt becomes "me / a chosen member / any" instead of "me / any". Picking a member completes over the team's members and passes their id (=:assignee-id=) for consistency with the favorite path. This makes another-person's-issues and every assignee combination reachable without a dedicated command. + +** Sort/order, caching, and exposure + +- *Sort/order (MP3):* favorite-derived filter sources carry no explicit =:sort= / =:order= and use the same default ordering as =pearl-list-issues=. Custom View favorites use the server's view order. Saved-query favorites keep their own =:sort=/=:order=. The user can sort the active file afterward. +- *Caching (MP1):* v1 fetches favorites fresh on each =pearl-pick-source= run; no favorites cache. If one is added later, =pearl-clear-cache= must clear it. +- *Exposure (MP2):* =pearl-pick-source= is added to =pearl-menu= (Fetch group) and to =pearl-fetch-map= at =s= (so =C-; L f s=), labeled "pick source". The existing direct commands stay. + +* Agreed decisions (v1) + +1. Favorites are read from Linear and run by type-dispatch to the matching fetch; favorites are picker entries, never persisted sources. +2. =pearl-pick-source= lists favorites (by =sortOrder=) + local saved queries (alphabetical), dispatching from an attached candidate plist. +3. =pearl-list-issues-by-project= drops the =:assignee :me= lock and renames its source to "Project issues: <name>". Breaking change; free (no users). +4. Label and User favorites resolve by *stable id* — the compiler grows =:label-id= and =:assignee-id= forms. Name/email forms remain for the builder. +5. Issue favorites are *browser-only* in v1 (open the URL); no =:type issue= source is added. +6. Non-issue favorites open in the browser with a message; a nil URL refuses cleanly. +7. The builder's assignee prompt gains a specific-member option (me / member / any), resolving by id. +8. Source model unchanged: only =:type filter= and =:type view= persist; refresh runs the resolved source, never the favorites list or saved-query store. +9. No favorites cache in v1 (fresh fetch); favorite-derived filters carry no explicit sort/order; =pearl-pick-source= exposed in menu + =f s=. +10. Local =pearl-saved-queries= are retained alongside favorites and surfaced in the same picker. + +* Files touched + +- =pearl.el=: =pearl--favorites-async= (paged query) + =pearl--normalize-favorite= + =pearl--favorite->source= + =pearl--open-favorite-url=; =pearl-pick-source= (candidate build + =completing-read= + dispatch); two compiler forms =:label-id= / =:assignee-id= in =pearl--build-issue-filter= (+ validation); fix =pearl-list-issues= / =pearl-list-issues-by-project= (drop me-lock, rename source); the builder's assignee prompt gains the member option; menu (=pearl-menu= Fetch group) and =pearl-fetch-map= (=s=) entries. +- Tests: a favorites fixture shaped exactly like the verified response (including a non-issue type and nullable url); =pearl--normalize-favorite= per type; =pearl--favorite->source= dispatch (each type → exact persisted source plist); the new compiler forms; candidate-builder (mixed favorites + saved queries, duplicate names, deterministic ordering); favorites-failure fallback (saved queries still appear); refresh stability (a Project/Label/User/Cycle favorite source refreshes without calling the favorites API); by-project all-assignees regression; builder member-assignee path; menu/keymap exposure (=tests/test-pearl-menu.el=, =tests/test-pearl-keymap.el=). +- =README.org=: a "Sources" section — favorites, Custom Views, saved queries, ad-hoc filter — with =pick-source= as the front door. + +* Migration + +Additive. =pearl-list-issues-by-project='s scope change (me → all assignees) and source-name change are the only behavior changes; no users, no migration. The new =:label-id= / =:assignee-id= compiler forms are additive (existing name/email forms unchanged). Existing local saved queries keep working and appear in the new picker. No defcustom removed; =pearl-saved-queries= retained. + +* Review dispositions + +Modified or rejected review points; everything else accepted as written. + +- *HP3 (modified).* The review offered label-id-or-name and assignee-id-or-email. I chose id-based for *both*, accepting the small compiler addition (=:label-id= / =:assignee-id=). Favorites are persisted, so resolution must be rename-proof; id also sidesteps the open question of whether =user.email= is reliably present on a favorite payload. The name/email forms stay for the ad-hoc builder, where the user is choosing live. +- *HP5 duplicate display (modified).* The review suggested disambiguating duplicate names with a URL/id suffix in the label. I disambiguate by the =[type]= tag first and append a suffix only on a true residual collision, to keep the completion list readable rather than suffixing every entry. +- *Pagination (clarified, not gated).* Adopted the review's "paginate if the connection paginates" for correctness, but recorded it as a low-risk path — favorites are effectively single-page in practice — so it does not gate v1. + +No findings rejected outright. HP1 (verify schema) is resolved by the live probe; HP2 / HP4 / MP1 / MP2 / MP3 and the architecture, robustness, and test-strategy observations are accepted and woven into the body above. + +* vNext / out of scope + +- Writing favorites from Emacs (star / unstar). v1 is read + run only. +- A =:type issue= single-issue active-file source, if rendering one favorited issue in the file (rather than the browser) proves wanted. +- Standalone by-label / by-cycle / by-team / by-state / by-priority commands, if favorites + the builder are not enough. +- Merging or sunsetting local =pearl-saved-queries= after favorites dogfooding. +- Favorites cache with TTL and =pearl-clear-cache= integration. +- Shared / team favorites, if Linear distinguishes them from the viewer's. diff --git a/docs/specs/labels-as-org-tags-spec.org b/docs/specs/labels-as-org-tags-spec.org new file mode 100644 index 0000000..cc15e1c --- /dev/null +++ b/docs/specs/labels-as-org-tags-spec.org @@ -0,0 +1,121 @@ +#+TITLE: pearl — Render Linear Labels as Org Tags Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*Review incorporated (Codex, 2026-05-25); rubric =Ready with caveats= -> ready.* The review's caveats — collision semantics, manual-edit overwrite rules, and the =pearl-set-labels= heading-rewrite contract — are folded into the body, and the two open questions are resolved as v1 decisions (preserve Unicode alnum in tags; Pearl owns the entire issue-heading tag set). Implements the =todo.org= task "Org tags should reflect the issue's Linear labels" (filed 2026-05-24 after a hardcoded personal =#+filetags:= value was removed in 952cfe7). Modified recommendations: none — everything accepted; see Review Dispositions. + +* Problem + +Pearl used to stamp a hardcoded personal =#+filetags:= value on every fetched file — file-wide, unrelated to any issue. That's gone. What's missing is the useful behavior: an issue's Linear labels should appear as org tags *on that issue's heading*, so the org-native gestures work — filter by tag, build a tag agenda, sparse-tree on =:bug:=. Today labels live only in the =:LINEAR-LABELS:= drawer (=[bug, backend]=), which org's tag machinery can't see. + +* Current state + +- =pearl--format-issue-as-org-entry= renders the heading as =** <STATE> [#P] <title>= with no tags, and writes the labels into the =:LINEAR-LABELS:= drawer as =[name, name]=. +- =pearl--normalize-issue= gives each issue =:labels= as a list of plists with =:name=. +- =pearl--issue-title-at-point= extracts the title with =(org-get-heading t t t t)= — the four flags strip the keyword, priority cookie, *tags*, and comment markers. So title sync is already tag-aware; adding heading tags won't corrupt the title round-trip. +- =pearl-set-labels= changes an issue's labels (completing-read over team labels), pushes, and rewrites the =:LINEAR-LABELS:= drawer (only the drawer today). + +* Proposed design + +** Tag slugify: label name → org tag + +A new pure helper =pearl--label-name-to-tag=. Org tags allow =[[:alnum:]_@#%]= — notably *no hyphens or spaces* (unlike TODO keywords, which allow hyphens). The rule: + +- Downcase (tags are case-sensitive in Org; lowercase is the convention). +- Replace each run of characters outside =[[:alnum:]_]= with a single =_=. +- Trim leading/trailing =_=. + +=[[:alnum:]]= in Emacs matches *Unicode* letters and digits, not just ASCII (MP1 decision): non-ASCII alphanumerics are *preserved*, downcased, since the rest of Pearl already carries label names as Unicode strings. Examples: =Bug= → =bug=, =Needs Review= → =needs_review=, =P1= → =p1=, =backend/api= → =backend_api=, =UI/UX= → =ui_ux=, =naïve café= → =naïve_café=. A label that slugifies to empty (punctuation-only) is dropped *from the heading* but stays in the drawer (MP2). + +(Contrast =pearl--state-name-to-keyword= from the workflow-states spec, which upcases and uses hyphens. Two targets, two slugify rules — separate, clearly-named helpers.) + +** Collision semantics (HP1) + +Org tags are a best-effort filter view, not a reversible label representation. If multiple Linear labels slugify to the same tag (=A/B= and =A B= both → =a_b=), the heading renders that tag *once*, at the first colliding label's position. =:LINEAR-LABELS:= remains the only exact, ordered list of label names. This lossiness is one reason bidirectional sync is out of scope. + +** Render tags on the issue heading + +A pure =pearl--label-tags= over the normalized label plists: slugify each, drop empties, de-dupe preserving order. =pearl--format-issue-as-org-entry= appends them in org tag syntax: + +: ** TODO [#B] Fix the thing :bug:backend: + +No labels → no tag string. Only *issue* headings get tags — the parent view heading and the Comments / individual-comment headings carry none. + +** Pearl owns the whole issue-heading tag set (HP3) + +On an issue heading, Pearl owns the *entire* tag set — there are no user-owned local tags mixed in (v1). =pearl--set-heading-label-tags= replaces all org tags on the current issue heading with the label-derived set, via Org APIs, preserving the TODO keyword, priority cookie, title text, identifier prefix, and the body/drawer. Clearing an issue's labels removes its heading tags. This single ownership rule is what makes manual edits predictably unsupported (below) and keeps the rewrite contract simple. + +** Manual heading-tag edits are overwritten / ignored (HP2) + +Org tags look editable, so the v1 rule is explicit: hand-editing a heading's tags is an unsupported derived-view edit. Concretely — + +- =pearl-set-labels= rewrites the heading tag set from the labels it *successfully pushed* (and the drawer), so a manually added tag disappears on the next label change; +- fetch / refresh rewrites heading tags from the remote labels (self-healing); +- =pearl-save-issue= / =pearl-save-all= and the dirty scanner *ignore heading tag diffs entirely* — a changed tag is never treated as a dirty field and never pushed. + +The way to change labels stays =pearl-set-labels=. + +** Keep the drawer authoritative + +=:LINEAR-LABELS:= remains the source of truth (exact Linear names, order, and labels that slugify lossily or to empty). The heading tags are the derived, org-native view. =pearl-set-labels= updates both after a successful push; a *failed* =pearl-set-labels= leaves both the drawer and the existing heading tags unchanged. + +** #+TAGS and inheritance (MP3, MP4) + +- =#+TAGS:= completion declaration is *deferred to vNext* (decision, not an open question): Org still filters, sparse-trees, and agenda-searches on undeclared tags present on headings, so completion isn't needed for the feature. +- Org tag *inheritance* (on by default) is *accepted* in v1: an issue's label tags inheriting onto its Comments subtree in agenda/sparse-tree contexts is normal, useful Org behavior. No file-local override. A README note only if users are likely to trip on it. + +** v1 is render-only (fetch direction) + +Editing the heading's tags by hand does not push to Linear in v1. Bidirectional sync — parse heading tags on save and reconcile against Linear's label set — is deferred: it needs a conflict gate like the description/title/comment syncs, label-*creation* semantics (a tag with no matching Linear label), and a way around the slugify lossiness above. + +* Proposed v1 decisions (this feature) + +1. =pearl--label-name-to-tag=: downcase, non-=[[:alnum:]_]= runs → =_=, trim; Unicode alnum preserved (MP1). +2. Collision → render the shared tag once at the first label's position; drawer keeps both names (HP1). +3. Empty-slug label dropped from the heading, kept in the drawer (MP2). +4. Pearl owns the entire issue-heading tag set; =pearl--set-heading-label-tags= replaces all heading tags, preserving keyword/priority/title/identifier/body/drawer (HP3). +5. Manual heading-tag edits are unsupported: =set-labels= and fetch/refresh rewrite them from authoritative state; save/dirty-scan ignore tag diffs (HP2). +6. Drawer authoritative; failed =set-labels= leaves drawer + tags unchanged. +7. =#+TAGS:= declaration deferred to vNext (MP3); tag inheritance accepted in v1 (MP4). +8. Render-only (fetch direction); bidirectional tag editing is vNext. + +* Open questions for Craig + +The review's two open questions are resolved as decisions 1 and 4 (preserve Unicode alnum; Pearl owns the whole issue-heading tag set), both per Codex's recommendation — flip either if you'd rather ASCII-only tags or room for user-owned local tags. Otherwise none blocking. + +* Files touched + +- =pearl.el=: new pure =pearl--label-name-to-tag= and =pearl--label-tags=; =pearl--set-heading-label-tags= (replace the heading's tag set via Org APIs); =pearl--format-issue-as-org-entry= (append the tag string on issue headings); =pearl-set-labels= (rewrite heading tags after a successful push). The =:LINEAR-LABELS:= drawer line is unchanged; the dirty scanner is explicitly *not* extended to tags. +- Tests: see below. +- =README.org=: note that labels render as heading tags for filtering, generated from Linear — "use =pearl-set-labels= to change them"; the drawer stays the structured store. + +* Test plan + +- *Slugify:* =Bug=→=bug=, =Needs Review=→=needs_review=, =UI/UX=→=ui_ux=, =P1=→=p1=, =naïve café=→=naïve_café= (Unicode preserved), punctuation-only → empty. +- *Render:* labels =("Bug" "Backend")= → =** … :bug:backend:= with =:LINEAR-LABELS: [Bug, Backend]= in the drawer. +- *Collision:* =("A/B" "A B")= → one =:a_b:= tag, drawer holds both names. +- *Empty slug:* a punctuation-only label → no heading tag, drawer still contains the name. +- *Title-sync regression:* with tags present, =pearl--issue-title-at-point= returns the bare title, so a no-op title sync still matches and a title edit round-trips. +- *set-labels:* after a successful change the heading tag set reflects the new labels (and the drawer); a *manually added* extra tag is gone afterward. +- *Clearing labels:* removes the heading tags and sets the drawer to =[]=. +- *Failed set-labels:* drawer and existing heading tags both unchanged. +- *Save isolation:* =pearl-save-issue= / the dirty scanner do not treat a tag-only heading change as a dirty field (no push). +- *Scope:* non-issue (parent view) and comment headings receive no tags. + +* Migration + +Additive — no breaking change. Existing files gain heading tags on the next fetch. The removed =#+filetags= is already gone (952cfe7). No defcustom changes. + +* Review Dispositions + +*Round 1 (Codex, 2026-05-25).* Rubric =Ready with caveats=. Everything accepted as written, no modifications or rejects. HP1 (explicit collision semantics — shared tag rendered once, drawer authoritative), HP2 (manual heading-tag edits overwritten by =set-labels=/refresh and ignored by save/dirty-scan), and HP3 (Pearl owns the whole issue-heading tag set; =pearl--set-heading-label-tags= replaces all tags preserving keyword/priority/title/identifier) drove the new design sections. MP1 (preserve Unicode alnum), MP2 (empty-slug label stays in the drawer), MP3 (=#+TAGS:= deferred to vNext), and MP4 (accept tag inheritance) are folded in as decisions. The two review open questions are resolved per Codex's recommendation (Unicode-preserve, Pearl-owns-tag-set); surfaced to Craig as flippable. The architecture, UX, and test recommendations are folded into the body and test plan. + +* vNext / out of scope + +- Bidirectional tag editing (heading tags → Linear labels) with a conflict gate and label-creation semantics. +- =#+TAGS:= completion declaration. +- Color/face mapping from Linear label colors to org tag faces. +- Mixed user-owned local tags alongside Pearl-owned label tags, if a real need appears. diff --git a/docs/specs/local-and-linear-views-spec.org b/docs/specs/local-and-linear-views-spec.org new file mode 100644 index 0000000..de8943a --- /dev/null +++ b/docs/specs/local-and-linear-views-spec.org @@ -0,0 +1,529 @@ +#+TITLE: Spec: local views and Linear views +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-01 + +* Status + +*Ready.* Six Codex review rounds incorporated (2026-06-01). Round one (=Needs research=): all eight open questions dispositioned with Craig and moved to *Agreed decisions*; the copy-down read-API blocker (HP1) cleared by a live probe that verified =CustomView.filterData= and corrected the spec's assumption about its shape (see *Verified API shape for copy-down*). Round two (=Not ready=, naming contract): with Craig's clarification that Pearl has no users, dropped the backward-compatibility/alias layer for direct renames, eliminated user-facing "query", made command names intent-first (=pearl-publish-local-view= / =pearl-save-linear-view-locally=), and defined cross-store duplicate display. Round three (=Not ready=, run/refresh semantics): defined that a tracked local view runs the *local* filter (the source of truth) rather than its Linear mirror, fixing a contradiction the response passes had left. Round four (=Not ready=, =:open=/=nin=): made Pearl's =:open t= predicate (which compiles to =state.type.nin=) a first-class reverse-compile case, resolving the contradiction between the round-trip promise and the blanket =nin= refusal. Round five (=Not ready=, reverse-compile precision): split =:state= from =:state-type=, made =:priority= round-trip to a canonical numeric form, and made multi-id label filters refuse (no plural authoring key in v1). Round six (=Not ready=, two boundary generalizations): generalized multi-value =in= refusal to all singular keys (=:assignee-id=, =:project=, =:cycle=, =:team=, label id), and widened the account-ownership guard from edit/copy to every local-view operation (run, publish, delete) with delete/publish refusing before any Linear call on mismatch. No open findings remain. See *Review dispositions*. Triggered by the 2026-06-01 conversation that started as "how do I create a saved query?" and surfaced that the saved-query operations are scattered inconsistently across the verb-first keymap: creation is hidden inside the filter builder and filed under fetch, deletion has a proper home, and editing doesn't exist at all. The conversation converged on a cleaner mental model — one familiar noun, =view=, qualified by where it lives: a *local view* (private, on your machine) and a *Linear view* (published, shared, the =CustomView= Linear users already know). This spec captures that model and the rename + reshape needed to adopt it. + +This is the successor framing to =docs/saved-query-sync-spec.org= (which shipped the local→Linear sync) and =docs/issue-sources-spec.org= (which unified the fetch surface behind =pearl-pick-source=). It does not redo their work; it renames and re-homes it so the whole lifecycle speaks one vocabulary. + +*2026-06-01 addition:* bidirectional copy. Craig asked for both directions as first-class operations — *copy down* (a Linear view → a new, editable, renamable local view) and *copy up* (a local view → a Linear view). Copy-down is the piece the first draft had deferred to vNext; it is now in scope, which pulls the =IssueFilter= reverse-compile (and its representability boundary) into the main design. + +* Problem + +Pearl already has the machinery for named, reusable filters and for pushing them up to Linear. What it lacks is a coherent *name* and a coherent *command shape*, and the gap shows up the moment a user goes looking for an operation by the noun: + +- The thing is called a "saved query" — a term Pearl invented. Linear has no "query" and no "saved query." A Linear user arrives knowing "filter" (the conditions) and "view" (the saved, shareable thing), and finds neither word. +- The command that *creates* a saved query is named =pearl-list-issues-filtered= ("build a filter"), lives under the *fetch* group (=C-; L f f=), and saves only as a yes/no afterthought at the end of the builder. The *create* group (=C-; L c=) holds "create issue" and "create comment" but no "create saved query." A user looking in the obvious place finds nothing. +- "saved query" appears under *delete* (=C-; L k q=) but not under *create*. It is a deletable noun that is not a creatable one — the asymmetry that sends people hunting. +- There is no *edit* at all. The edit group covers description / state / assignee / labels / comment (all issue-level); a saved query's filter, sort, or order can only be changed by delete-and-recreate or by hand-editing =pearl-saved-queries= in =init.el=. +- "view" is overloaded. =pearl-run-view= ("custom view", =C-; L f v=) runs a Linear =CustomView=; the transient has a "View" column for open-in-browser; and "viewing" a saved query is actually "fetch saved query". The word collides three ways with no home for the saved-filter concept. + +The CRUD lifecycle currently reads C(hidden, misfiled) R(fetch) U(absent) D(delete) — three of four verbs land where a user would not predict. + +* Non-goals + +- *Not* a rewrite of the sync engine. =customViewCreate/Update/Delete=, the collision (Replace/Rename/Cancel) flow, the scope/shared prompt, and the =:linear-view-*= tracking metadata all stay as built in =saved-query-sync-spec.org=. They get renamed, not redesigned. +- *Not* a change to how issues render or save. This spec touches the source/view surface only. +- *Not* OR-logic filters. Local views stay AND-only in v1, same as saved queries today; OR lives in a Linear Custom View authored on Linear's side. +- *Not* syncing =:sort= / =:order= up to Linear. Still unsupported by =CustomViewCreateInput= (probe-confirmed 2026-05-28); sort/order stay local-only conveniences. + +* The model + +One noun — *view* — with a qualifier that names where it lives. The qualifier is the only real difference: visibility and location. + +| Concept | Name | Linear's own term | +|--------------------+---------------+-------------------------------------------| +| The conditions | filter | filter (=IssueFilter=) | +| Saved, on disk | local view | (no analog — a local filter you named) | +| Published, shared | Linear view | View (=CustomView=, wraps an =IssueFilter=) | + +The lifecycle is git-shaped, which is the analogy that makes it self-explanatory: + +| Pearl | git | +|--------------------------------------------+-------------------------------------------| +| local view | local branch | +| Linear view | remote branch | +| copy up (local view → Linear view) | push — creates a tracked Linear mirror | +| copy down (Linear view → local view) | branch off — a new, independent local view | +| the =:linear-view-id= link | the upstream tracking ref (copy-up only) | + +A view can have a local copy, a Linear copy, or both. Pearl already stores exactly this: a =pearl-saved-queries= entry with no =:linear-view-id= is local-only; one *with* a =:linear-view-id= is a local view that has been published and now tracks a Linear view. The data model is already git-shaped — this spec names it. + +*Copy is the verb for moving a view between the two stores, in both directions, and the two directions are deliberately asymmetric:* + +- *Copy up* (local view → Linear view) is *publish* — it creates a Linear view and keeps the =:linear-view-id= link, so editing the local view and copying up again *updates the same Linear view in place* rather than spawning duplicates. This is the existing sync machinery. +- *Copy down* (Linear view → local view) is a *fork* — it duplicates the Linear view's filter into a new, independent local view you then own, edit, and rename freely. It does *not* track the source: you copied it down precisely to diverge from it, and once you rename it, a live link would only mislead. Provenance may be recorded for reference (a =:copied-from-view-id=), but it is not a sync link. + +The asymmetry follows from one principle: *the local view is the editable source of truth.* Copy-up keeps a Linear mirror synced to that source; copy-down forks a Linear view into a *new* local source you take ownership of. If, after copying down and editing, you want it back on Linear, you copy up — which creates a new, separately tracked Linear view. + +Two facts the model has to keep honest: + +1. *=filter= is not retired.* It stays the conditions-building step (Linear's "Filter" button) and the on-disk authoring plist. You *filter* to assemble conditions; when you name and keep the result, it is a *local view*. An unsaved run is an *ad-hoc filter* — not yet any kind of view. +2. *Not every Linear view has a local view behind it.* A =CustomView= authored in Linear's web UI, or a teammate's favorite, is a Linear view with no local tracking copy — a remote branch with no local branch. Pearl runs those via the picker today; the model accommodates them as Linear-only views. + +* Current state (what exists, what's missing) + +** Exists + +- =pearl-saved-queries= (defcustom, alist =(NAME . SPEC)=) — the local-view store. SPEC carries =:filter=, optional =:sort= / =:order=, and (when published) =:linear-view-id=, =:linear-view-team-id=, =:linear-view-shared=, =:linear-view-url=. +- =pearl-list-issues-filtered= (=C-; L f f=) — builds an ad-hoc filter interactively, runs it, and offers to save it (=pearl--save-query=, persisted via =customize-save-variable=). This is the de-facto *create* path. +- =pearl-run-saved-query= (=C-; L f q=) — runs a named local view's stored filter. +- =pearl-delete-saved-query= (=C-; L k q=) — deletes a local view, optionally its linked Linear view too. +- =pearl-sync-saved-query-to-linear= (=C-; L f S=) — publishes a local view as a =CustomView=; first sync prompts scope + visibility, re-sync updates in place, name collisions prompt Replace/Rename/Cancel; stamps the =:linear-view-*= keys back. +- =pearl-publish-current-source= (=C-; L f P=) — reads the buffer's =#+LINEAR-SOURCE=; if it names a local view, publishes that one. +- =pearl-run-view= (=C-; L f v=, "custom view") — runs a Linear =CustomView= by id. +- =pearl-pick-source= (=C-; L f s=) — the unified runner: lists Linear favorites first, then local views, each tagged (=[saved]=, =[saved → SCOPE]=, =[KIND]=). Already spans the local/Linear split and dispatches synced entries through the view branch. +- =pearl--saved-query-scope-label= / =pearl--pick-source-candidates= — render the kind/scope labels. + +** Missing + +- No *create* command under the create group — creation is a side effect of the filter builder. +- No *edit* command at all — a local view's filter/name/sort/order can't be changed in Emacs. +- No *copy down* — a Linear view authored on Linear's side can be *run* but not copied into an editable local view (requires reverse-compiling =IssueFilter= back into Pearl's authoring plist; the compiler is currently one-way). +- The vocabulary: nothing says "view"; everything says "saved query". + +* Proposed design + +** Vocabulary + +Adopt =filter= / =local view= / =Linear view= everywhere user-facing: command names, labels, prompts, transient groups, README, docstrings. "saved query" disappears from the surface entirely. Pearl has no users yet (not on MELPA, no published repo), so there is no compatibility surface to protect — the renames are *direct*, with no obsolete aliases keeping "query" alive in completion, tests, or docs. Command names are intent-first: a user reaches for "publish this local view" or "save this Linear view locally", not "copy" or "source" jargon. + +** Rename map + +Direct renames — no obsolete aliases. "query" survives only inside internal transport helpers (where it means a GraphQL query) and in this spec's "Current state" / history text describing the pre-change code. + +| Today | Renamed to | +|------------------------------------+--------------------------------| +| =pearl-saved-queries= (defcustom) | =pearl-local-views= | +| =pearl-run-saved-query= | =pearl-run-local-view= | +| =pearl-delete-saved-query= | =pearl-delete-local-view= | +| =pearl-sync-saved-query-to-linear= | =pearl-publish-local-view= | +| =pearl-run-view= | =pearl-run-linear-view= | +| =pearl-publish-current-source= | =pearl-publish-current-view= | + +New commands: + +| New | What it does | +|------------------------------+------------------------------------------------------------------------------| +| =pearl-create-local-view= | Build a filter interactively and save it as a named local view (create slot) | +| =pearl-edit-local-view= | Edit an existing local view: re-run the builder pre-seeded with its filter, and adjust name / sort / order | +| =pearl-save-linear-view-locally= | *Copy down.* Pick a favorited Linear view, reverse-compile its filter, and save it as a new, independent, editable local view (prompts for a name) | + +Copy-up *is* publish, so its command is =pearl-publish-local-view= (the direct rename of =pearl-sync-saved-query-to-linear=) — no separate "copy to Linear" command. The lifecycle prose still calls the two directions "copy up" and "copy down"; the commands say what the user is doing: =pearl-publish-local-view= and =pearl-save-linear-view-locally=. + +=pearl-create-local-view= is the deliberate create path. =pearl-list-issues-filtered= stays as the ad-hoc filter run and *keeps its inline save offer*, reworded from "save this filter?" to "save this local view?" so the prompt names what it produces. So there are two doors to creating a local view: the dedicated command, and the save offer at the end of an ad-hoc run. The three share builder internals (=pearl-list-issues-filtered=, =pearl-create-local-view=, =pearl-edit-local-view=). + +The unified runner stays =pearl-pick-source=: it picks among favorites, local views, by-dimension filters, and browser-only favorites, so "source" is accurate — it is genuinely more than views, and the =issue-sources-spec= already established the word. Renaming it "run view" would misrepresent what it runs. + +** Command surface, by verb + +Every view operation hangs off the *view* noun under the verb where a user would look: + +- *create* a local view — =pearl-create-local-view= +- *edit* a local view — =pearl-edit-local-view= +- *run* a view (local or Linear) — =pearl-pick-source= (unified) plus direct =pearl-run-local-view= / =pearl-run-linear-view= +- *delete* a local view — =pearl-delete-local-view= +- *publish* a local view → Linear view (copy up) — =pearl-publish-local-view= (and =pearl-publish-current-view= for the current buffer's view) +- *save locally* a Linear view → local view (copy down, fork) — =pearl-save-linear-view-locally= + +** Keymap reshape + +The =view= noun is reachable under the verb a user would look for. The view-movement commands (run, publish, save-locally) live in the fetch group alongside the other ways to pull issues into a buffer; create/edit/delete sit under their own verb groups. The copy prefix =C-; L y= keeps its literal copy-to-clipboard meaning (=y u= copy-issue-URL) and gains no view commands — those are "publish" and "save locally", not clipboard copies. + +| Chord | Command | +|-------------+------------------------------------------| +| =C-; L c v= | =pearl-create-local-view= | +| =C-; L e v= | =pearl-edit-local-view= | +| =C-; L k v= | =pearl-delete-local-view= | +| =C-; L f s= | =pearl-pick-source= (run any view) | +| =C-; L f l= | =pearl-run-local-view= | +| =C-; L f v= | =pearl-run-linear-view= | +| =C-; L f u= | =pearl-publish-local-view= (copy up; =u= = up) | +| =C-; L f P= | =pearl-publish-current-view= | +| =C-; L f d= | =pearl-save-linear-view-locally= (copy down; =d= = down) | + +(=f p= stays "by project", so publish/save-locally take =u=/=d= = up/down, which also reads as the copy-up/copy-down lifecycle. The transient groups the view commands under a "Views" column.) + +The old =q= (saved query) bindings are removed outright in the same change, not deprecated — there are no users to ease through a transition, and Craig's own config is updated alongside the rename. + +** Transient reshape + +The =pearl-menu= "Workspace" section currently splits Fetch / View / Setup. Reword so the run/publish/create/edit/delete of views read as one family. A "Views" sub-group listing create / edit / run local / run Linear / publish / delete keeps the noun together, while ad-hoc filtering and the by-dimension fetches stay in Fetch. + +** Picker labels + +Reword =pearl--pick-source-candidates= / =pearl--saved-query-scope-label= so the local/Linear split is legible at a glance: + +- local-only: =[local] NAME= +- published local: =[local → Linear:SCOPE] NAME= (shows the tracking link and where it lives) +- native Linear view / favorite: =[linear] TITLE= or =[KIND] TITLE= + +This directly answers the "which of my views are public?" question Craig wanted to see — the label *is* the public/private indicator. + +** Cross-store duplicate display + +The picker draws from two stores, so the same view can show up twice. The rule has to be deterministic before labels and tests can be written: + +- *Same =CustomView= id in both stores* — a published local view (tracking =:linear-view-id= X) and a favorited Linear view that is also X. Dedupe to the *local tracked entry* (=[local → Linear:SCOPE]=): it carries the editable local source, and selecting it runs the *local* filter (see *Run and refresh semantics* below), so you land on the thing you can edit. The favorite is the same server view, so showing it separately would only confuse ("why is this here twice?"). +- *Same name, different or no id* — a local view "My bugs" and an unrelated Linear view "My bugs" are legitimately different and must both stay runnable. Show both, each with its qualified label (=[local]= vs =[linear]=); add the =(#N)= suffix only on an exact display-string collision, which the labels already make rare. +- Copy-down's default name still runs through the local Replace/Rename/Cancel policy; publish still uses Linear Replace/Rename/Cancel for target-scope name collisions. The cross-store rule is only about *display*, not about writing. + +Tests cover same-name local vs Linear, same-id tracked-local plus favorite (deduped), and duplicate Linear favorite titles. + +** Run and refresh semantics + +A published local view has *two* filters once you edit it locally and before you publish again: the local authoring plist (the source of truth) and the last-published Linear =filterData= mirror. The spec has to say which one each command runs, or the implementer is left guessing — and the current code guesses the wrong way for the new model (=pearl--pick-source-candidates= turns a synced entry into =(:type view :id …)= and dispatches through =pearl--query-view-async=, running the server-side mirror). + +v1 makes runtime behavior follow the model — *the local view is the source of truth, so local commands run local*: + +- =pearl-run-local-view= runs the *local authoring filter*, always — even when the entry is tracked (=:linear-view-id= set). It compiles and runs the local plist, not the Linear view. +- =pearl-run-linear-view= runs the *Linear =CustomView= by id* — the server-side version, the way to deliberately run what's published (including any richer filter that only lives on Linear). +- =pearl-pick-source=, after deduping a same-id tracked-local-plus-favorite to the local entry, runs the *local* filter — consistent with the =[local → Linear:SCOPE]= label and the source-of-truth rule. (To run the server-side mirror, use =pearl-run-linear-view=.) +- A buffer rendered from a local view records a =:type filter= source carrying the local view's *name* + filter + sort + order, distinct from an ad-hoc filter's anonymous descriptor; =pearl-refresh-current-view= re-runs that local filter. +- A buffer rendered from a *native* Linear view records a =:type view= source; refresh runs Linear server-side. + +This *changes current behavior*: today a tracked entry runs through the Linear branch; under the new model it runs local. The change is the point — reusing =:type view= for a tracked local view would preserve the old behavior but undermine the ownership model. The local-vs-mirror divergence window (after edit, before publish) is real but unflagged in v1; a visible "unpublished changes" indicator is vNext. + +** Performance bounds + +v1 is deliberately cheap, and the spec states the bounds so they don't drift: + +- Copy-down lists *favorited* Linear views only (no all-Custom-Views enumeration, no pagination), and fetches =customView(id){ filterData }= lazily, only after the user selects one. Reverse-compile runs on a single small filter tree. +- Publish reuses the existing single-view create/update path. +- The unified runner fetches favorites fresh (as today) and resolves team names for =Linear:SCOPE= labels only when at least one tracked local view exists — the existing conditional, kept. + +All-Custom-Views copy-down stays vNext precisely because it is the part that needs pagination and a cache path. + +** Local view persistence and edit semantics + +Create, edit, and copy-down all write a local-view entry, and the current writer is not safe for them. =pearl--save-query= rebuilds an entry from =:filter= / =:sort= / =:order= only, so reusing it to edit a *published* local view would silently drop the tracking keys (=:linear-view-id=, =:linear-view-team-id=, =:linear-view-shared=, =:linear-view-url=, =:linear-view-synced-at=) and any =:account= or =:copied-from-view-id=. A user who edits a published local view and then copies up again expects the *same* Linear view to update; a dropped link turns that into a fresh publish and a duplicate Linear view. (=pearl--save-query-mark-synced= already does the right thing for its own keys — copy the plist, =plist-put= the changed keys, leave the rest.) + +- *Metadata-preserving writer.* Introduce =pearl--save-local-view= that copy-updates the existing entry by name, replacing only the fields the operation changed and preserving every other key. Create/edit/copy-down all go through it. The only path that clears =:linear-view-*= is an explicit future "unlink" operation (out of scope here). +- *Edit = pre-seeded re-run.* =pearl-edit-local-view= re-opens the builder seeded with the stored filter, and lets the user adjust the name, sort, and order. It writes through =pearl--save-local-view=, so a published view stays published and a copied-down view keeps its provenance. +- *Account tag (multi-account) — one guard, every operation.* A local view created or copied down under =pearl-accounts= is stamped with the active =:account=. The guard that =pearl-run-saved-query= already applies (refuse an entry tagged to a non-active account) extends to *every* local-view operation that reads or mutates an entry: run, edit, publish, publish-current-view, delete/unlink, and save-over. A single =pearl--require-local-view-account= helper enforces it so the check isn't re-derived per command. The remote-mutating ones matter most: publishing or deleting under the wrong account resolves ids against the wrong workspace or targets the wrong remote — so on an account mismatch, =pearl-delete-local-view= refuses *before* any =customViewDelete=, and =pearl-publish-local-view= refuses before any =customViewCreate/Update=. Copy-down only lists and fetches favorited Linear views from the *active* account and stamps the new local view with it. Legacy single-account mode and untagged legacy entries behave exactly as today (no guard). + +*Local name-collision policy.* =pearl--save-query= today replaces a same-name entry with no prompt — fine when the only writer was the explicit create, dangerous now that copy-down and edit-rename can land on an existing name. Local views are user-authored config, so a silent overwrite is data loss. One policy applies to create, edit-rename, and copy-down: when the target local name already exists, prompt Replace / Rename / Cancel. Default to *Rename* for copy-down and edit-rename (the common case is "I want a new one"); require an explicit confirm for *Replace*. This mirrors the Replace/Rename/Cancel flow copy-up already uses against Linear, so the two collision surfaces read the same. + +** Copy up (local view → Linear view) + +This is the existing publish/sync path, unchanged in behavior. =pearl-publish-local-view= (the direct rename of =pearl-sync-saved-query-to-linear=) picks a local view, creates a =CustomView= on first publish (prompting scope + visibility), and on a re-publish updates the tracked view in place via =customViewUpdate= against the stored =:linear-view-id=. Name collisions in the target scope prompt Replace/Rename/Cancel. The only change here is vocabulary: "sync" becomes "publish" (the lifecycle's copy-up direction). No new engine work. + +** Verified API shape for copy-down (probed 2026-06-01) + +A live probe against =https://api.linear.app/graphql= settled the read-API blocker (HP1) and, more usefully, corrected an assumption the first draft got wrong. + +- *=CustomView.filterData= is readable and non-null.* Introspection: =filterData= is =NON_NULL JSONObject=. =customViews(first:N){ nodes { id name filterData } }= returns it for every Custom View the user can access. So the read path exists: enumerate Custom Views, read =filterData=. +- *But its shape is NOT the =IssueFilter= input Pearl emits.* The first draft assumed =filterData= would mirror =pearl--build-issue-filter='s output (a flat AND of sibling keys). It does not. Linear stores the *view-filter* serialization, which is an =and=/=or= tree: + - The top level is wrapped in ={ "and": [ ... ] }= even for a single condition. + - Single sub-conditions are often wrapped in their own =or= array, e.g. =assignee: { "or": [ { "isMe": { "eq": true } } ] }=. + - Scalars are expressed as =in= arrays, e.g. =project: { "id": { "in": [ID] } }= and =state: { "name": { "in": ["Needs Review"] } }= — where Pearl's compiler emits the =eq= scalar form. + - An unfiltered grouping view returns ={}= (no constraints). + - Richer real views appear too, e.g. a label condition serialized as =labels: { "and": [ { "or": [ { "name": { "eq": "Chore" } }, { "parent": { "name": { "eq": "Chore" } } } ] } ] }= — OR plus a label =parent= dimension Pearl does not model. + +The consequence: copy-down is not a one-to-one inversion of Pearl's compiler. It is a *normalize-then-match* over Linear's view-filter tree. + +** Copy down (Linear view → local view) + +Copy-down forks a Linear view into a new, editable, renamable local view. Because the result must be editable, an opaque run-only copy is not enough — the local view has to carry a real authoring plist that =pearl-edit-local-view= can re-open. + +*Reverse-compile = normalize, then match.* + +1. *Normalize* the view-filter tree toward Pearl's flat-AND model: flatten the top-level =and= array into a conjunction set; unwrap any single-element =and= / =or= wrapper (a one-branch =or= is not a real disjunction); read an =in= array of length one as the scalar value Pearl's authoring keys expect. +2. *Match* each normalized conjunct against a Pearl authoring dimension and emit the authoring key: + +| Authoring key (emit) | Normalized view-filter conjunct it matches | Shape status | +|------------------------+-------------------------------------------------------+---------------------------| +| =:assignee :me= | =assignee.isMe.eq = true= (after unwrapping =or=) | primary | +| =:assignee-id= ID | =assignee.id.in=[ID]= / =.eq= (after unwrapping =or=) | primary (builder default) | +| =:assignee= EMAIL | =assignee.email.eq= | legacy / back-compat | +| =:state= NAME or NAMES | =state.name.eq= / =state.name.in=[NAME…]= (one or a list) | primary (=pearl--compile-state-filter=) | +| =:state-type= TYPE(S) | =state.type.eq= / =state.type.in=[TYPE…]= (one or many) | primary (=pearl--compile-state-filter=) | +| =:open t= | =state.type.nin=["completed","canceled","duplicate"]= (order-insensitive, exactly that set) | primary — Pearl's open predicate | +| =:project= ID | =project.id.in=[ID]= / =.eq= | primary | +| =:team= KEY | =team.key.eq= | primary | +| =:label-id= ID | =labels.some.id.eq= / single =labels.some.id.in=[ID]= | primary (rename-proof); multi-id refuses | +| =:labels= (NAMES) | =labels.some.name.in=[NAME…]= | legacy / back-compat | +| =:priority= N | =priority.eq=N= (N = 0..4) | primary (canonical numeric) | +| =:cycle= ID | =cycle.id.in=[ID]= / =.eq= | primary | + +The id-based forms are what the current interactive builder writes (=:assignee-id=, =:label-id=) and are rename-proof; the email/name forms remain for old hand-authored entries and must round-trip too, but they are back-compat, not the target a fresh copy-down produces. Round-trip tests run both ways (authoring plist → =build-issue-filter= → reverse → identical plist, with the priority and label caveats below) per dimension, plus normalize-from-real-view-filter tests for the wrapper and =in=[single]= cases. + +Three current compiler shapes need a precise rule, because the compiled Linear filter loses information the authoring plist had: + +- *=:state= vs =:state-type= are distinct keys.* =:state= (a name or a list of names) compiles to =state.name.eq= / =state.name.in=; =:state-type= TYPE-OR-LIST= compiles to =state.type.in=. The reverse must emit the matching key — =state.type.in= becomes =:state-type=, never =:state= — and both still take precedence over =:open= as =pearl--compile-state-filter= does. (=:state= is plural-capable per the multi-state-filter spec: a multi-name =state.name.in= reverse-compiles to =(:state (list))=.) +- *=:priority= canonicalizes to the integer.* =pearl--compile-priority= maps both =(:priority high)= and =(:priority 2)= to =priority.eq=2=, so the symbol-vs-integer choice is not recoverable. Copy-down therefore emits the *canonical numeric* form: =priority.eq=N= reverse-compiles to =(:priority N)= for N in 0..4; any other value refuses. The "identical plist" round-trip promise holds with this one documented canonicalization — a symbol-authored filter round-trips to its numeric equivalent, which compiles to the same Linear filter. +- *=:label-id= is singular.* Pearl has no plural =:label-ids= authoring key, so =labels.some.id= with one id (=.eq= or one-element =.in=) emits =:label-id=, and =labels.some.id.in= with more than one id *refuses* in v1 (refuse-don't-guess — collapsing to one id or inventing a key would both be wrong). =labels.some.name.in= with any number of names stays representable as =:labels=. + +*The general singular-vs-plural =in= rule.* The label case is one instance of a rule that covers every dimension: *a singular authoring key accepts only =eq= or a one-element =in=; a multi-value =in= on a singular key refuses.* Pearl's singular keys are =:assignee-id=, legacy =:assignee= (email), =:project=, =:cycle=, =:team=, and =:label-id= — so =assignee.id.in=[A,B]=, =assignee.email.in=[A,B]=, =project.id.in=[P1,P2]=, =cycle.id.in=[C1,C2]=, =team.key.in=[T1,T2]=, and multi-id =labels.some.id.in= all refuse: no =:assignee-ids= / =:project-ids= / … key exists, and collapsing to one value would silently narrow the view. The keys that accept a multi-value =in= are the genuinely plural ones — =:labels= (from =labels.some.name.in=[NAMES…]=), =:state-type= (from =state.type.in=[TYPES…]=), and =:state= (from =state.name.in=[NAMES…]=, added by the multi-state-filter spec). Plural authoring keys for the remaining singular dimensions are vNext. + +*=:open= is the one recognized negation.* Pearl's =:open t= is the common "not closed" predicate and compiles (via =pearl--compile-state-filter=) to =state.type.nin=["completed","canceled","duplicate"]= — and Pearl's favorite-derived source filters (=(:project ID :open t)=, =(:label-id ID :open t)=, =(:assignee-id ID :open t)=) lean on it heavily. The reverse-compiler matches *exactly* that =state.type.nin= set, order-insensitive, back to =:open t=. This is the lone =nin= the matcher accepts; every other =nin= still refuses (see below). When a normalized view also carries an explicit =state.name= or =state.type= condition, that takes precedence over =:open= exactly as the compiler does, so the round-trip stays faithful. + +*Representability boundary — refuse, don't guess (Open decision 3).* After normalization, anything Pearl's authoring model can't hold makes copy-down *refuse with a message naming the construct*, rather than drop it: + +- a *real* multi-branch =or= or nested =and= / =or= that survives normalization (genuine disjunction); +- a dimension Pearl doesn't model — =parent=, =creator=, =subscribers=, =dueDate=, =estimate=, project milestone, and the rest; +- an operator outside the matched set — =neq=, generic =nin= (except Pearl's exact open-state predicate, matched to =:open t= above), =null=, date/number comparators, =labels.every= / =labels.none=; +- a multi-value =in= on any *singular* key — =assignee.id.in=, =assignee.email.in=, =project.id.in=, =cycle.id.in=, =team.key.in=, and multi-id =labels.some.id.in= — refuses, because Pearl has no plural authoring key for those dimensions and collapsing to one value would silently narrow the view. The keys that *do* accept multiple values are =labels.some.name.in= (=:labels=), =state.type.in= (=:state-type=), and =state.name.in= (=:state=, plural-capable per the multi-state-filter spec). + +A diagnostic helper returns a *structured reason* (the offending path + construct), and the command formats it: "this Linear view filters on a label parent / uses OR logic / filters by due date, which Pearl's local views can't represent yet — run it directly with =pearl-run-linear-view= instead." A local view that looks like the Linear view but quietly matches a different issue set, diverging on every refresh, is the worst outcome — so copy-down never produces a lossy local view. The probe's own sample bears this out: of seven real Custom Views, the team/project/state/assignee ones normalize cleanly, the "Chore" label-parent-OR view refuses, and the three empty grouping views (={}=) copy down to a no-constraint filter that =pearl-create-local-view='s own "needs at least one constraint" guard then rejects (so an empty view refuses too, with that message). + +*Candidate source (Open decision 9): favorited Linear views only, v1.* Copy-down lists the user's *favorited* Linear views — favorites whose normalized =:kind= is =view=, which already carry a Custom View id. That set is small and already bounded, so it reuses the existing favorites enumeration and sidesteps Custom-View pagination entirely. Empty state names the fix: "favorite a Linear view in Linear first, then copy it down." Enumerating *all* Custom Views (favorited or not) via a paged =filterData= query is deferred to vNext; it is the only part that needs pagination and a separate cache path. + +*Mechanics and messages (MP3).* The chosen view's =filterData= is fetched (one =customView(id){ filterData }= request), normalized, and reverse-compiled. On success it is saved through =pearl--save-local-view= as a new local view under a prompted name (defaulting to the Linear view's name, subject to the local collision policy above), with no =:linear-view-id= — it is a fork — and an optional =:copied-from-view-id= recorded for provenance only. Progress and result messages: "Fetching filter for <view>…", the refusal message above when unrepresentable, and a success message that states the independence so the user isn't surprised later: "Saved Linear view <name> as independent local view <name>; edits won't update the original Linear view until you publish it back." + +*Helpers (architecture).* Keep the command thin over pure helpers, matching Pearl's existing compiler-plus-wrapper shape: =pearl--reverse-compile-issue-filter= (normalize + match, returns the authoring plist or a structured refusal), =pearl--issue-filter-representable-p= (the diagnostic, returns reasons not formatted strings), =pearl--save-local-view= (the metadata-preserving writer), and a candidate-builder separate from =completing-read=. + +* Migration + +- =pearl-saved-queries= is renamed to =pearl-local-views= directly — no =define-obsolete-variable-alias=. Pearl has no external users, so there is no config in the wild to forward. The on-disk alist shape (=:filter= / =:sort= / =:order= / =:linear-view-*=) is unchanged, so the stored data carries over untouched; only the variable name changes. +- Commands are renamed directly too — no =define-obsolete-function-alias=. The old =q= saved-query keybindings are removed in the same change rather than deprecated. +- *Craig's own config is the only thing to update* (he's the sole user, dogfooding). Anything in his =init.el= that sets =pearl-saved-queries= or binds the old commands/keys is updated alongside the rename, in the same change. There is no alias safety net and none is wanted — the point of the rename is that "query" stops existing. +- =#+LINEAR-SOURCE= file headers are unaffected — they record a source descriptor, not the command name. Internal source plists (=:type 'view= / =:type 'filter=) may be renamed in lockstep or left as-is; they are not user-facing. +- README, =package-summary.md=, and the transient help strings switch to the new vocabulary in the same change. + +* Agreed decisions + +All settled with Craig on 2026-06-01, across the first open-question pass and the second (naming-contract) review. Nothing here is still open. + +1. *No backward compatibility* — Pearl has no users, so the renames are direct: no obsolete variable/function aliases, and the old =q= saved-query bindings are removed outright. Craig's own config is updated in the same change. +2. *Variable name* — =pearl-saved-queries= → =pearl-local-views= (direct rename). +3. *Eliminate user-facing "query"* — "query" survives only inside internal GraphQL transport helpers and historical text. Nothing a user types, reads, or completes says "query". +4. *Intent-first command names.* Copy-up's command is =pearl-publish-local-view= (publish is the discoverable word, not "copy to Linear"). Copy-down's command is =pearl-save-linear-view-locally=. "Copy up / copy down" stays as lifecycle prose only. +5. *Unified runner stays =pearl-pick-source=* — it runs favorites, local views, by-dimension filters, and browser-only favorites, so "source" is accurate and it's the word =issue-sources-spec= already established. Not renamed to a view-only word. +6. *Ad-hoc builder* — =pearl-list-issues-filtered= keeps its inline save offer, reworded to "save this local view?". A dedicated =pearl-create-local-view= is the other door. +7. *Keymap* — view-movement commands (run / publish / save-locally) sit in the fetch group (=f l= run local, =f v= run Linear, =f u= publish, =f d= save-locally; =f p= stays "by project"); create/edit/delete under =c v= / =e v= / =k v=. The copy prefix =C-; L y= keeps its clipboard meaning only. +8. *Copy-down representability* — refuse with a structured, named reason; never a lossy or opaque copy. +9. *Copy-down tracking* — fork: no =:linear-view-id=, only optional =:copied-from-view-id= provenance. +10. *Edit semantics* — pre-seeded builder re-run that preserves all non-edited metadata (via =pearl--save-local-view=). +11. *Local name-collision policy* — Replace / Rename / Cancel for create, edit-rename, and copy-down; default Rename for copy-down and edit-rename; explicit confirm for Replace. +12. *Cross-store duplicate policy* — when a published local view and a favorited Linear view point at the *same* =CustomView= id, the picker dedupes to the local tracked entry (it carries the editable source). A local view and a Linear view that merely share a *name* (different or no id) are both shown with their qualified labels; the =(#N)= suffix appears only on an exact display collision. +13. *Copy-down candidate source* — favorited Linear views only in v1; all-Custom-Views enumeration deferred to vNext. +14. *Picker labels* — =[local] NAME= / =[local → Linear:SCOPE] NAME= / =[linear] TITLE=. +15. *Account behavior* — local views created or copied down under =pearl-accounts= are stamped with the active =:account=; edit preserves it. One guard refuses *every* read/mutate operation (run, edit, publish, publish-current, delete/unlink, save-over) on a cross-account entry; delete and publish refuse before touching Linear. Legacy single-account / untagged entries are unguarded. +16. *Run/refresh semantics* — the local view is the runtime source of truth: =pearl-run-local-view= and a deduped =pearl-pick-source= selection run the *local* filter even for a tracked entry; =pearl-run-linear-view= runs the server-side mirror. A local-view buffer is a =:type filter= source that refreshes via its local filter. This reverses the current code, which runs tracked entries through the Linear branch. + +* Acceptance criteria + +- Every view operation (create, edit, run, delete, publish, save-locally) is reachable under the verb a user would predict, all hanging off the =view= noun. +- *Naming regression:* no user-facing command, prompt, transient label, defcustom, or README / package-summary text contains "saved query" or a bare "query" for this feature. The old =pearl-saved-queries= / =pearl-*-saved-query= / =pearl-run-view= symbols are gone (renamed, not aliased), and a grep for them over the user-facing surface returns nothing. "query" survives only in internal GraphQL helpers. +- Command-name coverage for the public API: =pearl-create-local-view=, =pearl-edit-local-view=, =pearl-run-local-view=, =pearl-delete-local-view=, =pearl-publish-local-view=, =pearl-save-linear-view-locally=, =pearl-run-linear-view=, =pearl-pick-source=. +- The picker, transient, keymap help, prompts, README, and docstrings all speak =filter= / =local view= / =Linear view= / =publish= / =save locally=. +- =pearl-create-local-view= and =pearl-edit-local-view= exist and round-trip a local view (create → edit → run → publish → delete) with tests. +- *Edit preserves metadata:* editing a published local view keeps =:linear-view-*=, =:account=, and =:copied-from-view-id=, changing only the requested fields; a later copy-up updates the same Linear view by id rather than publishing a duplicate. Tested. +- *Local collisions are safe:* create, edit-rename, and copy-down prompt Replace/Rename/Cancel on a name clash and never overwrite by default. Tested for all three. +- *Copy-down round-trip:* every =pearl--build-issue-filter= shape reverse-compiles back to its authoring plist (with the priority canonicalization noted below) — =:assignee :me=, =:assignee-id=, legacy =:assignee= email, =:state=, =:state-type=, =:open t=, =:project=, =:team=, =:labels=, =:label-id=, =:priority=, =:cycle= — and the normalizer handles the real view-filter wrappers (top-level =and=, single-element =or=, =in=[single]=). Normal/Boundary/Error. +- *State keys are distinct:* =(:state "In Progress")= round-trips to =:state=; =(:state-type "started")= and =(:state-type ("started" "unstarted"))= round-trip to =:state-type= (single and list); =state.type.in= never reverse-compiles to =:state=. =:state= / =:state-type= take precedence over =:open=. +- *Priority canonicalizes to numeric:* =(:priority 2)= round-trips exactly; =(:priority high)= builds to =2= and reverse-compiles to canonical =(:priority 2)=; =priority.eq= outside 0..4 refuses. +- *Label id is singular:* single =labels.some.id= round-trips to =:label-id=; =labels.some.id.in= with more than one id refuses (no =:label-ids= key in v1); =labels.some.name.in= round-trips to =:labels=. +- *=:open t= specifically:* =(:open t)= and the favorite-derived =(:project ID :open t)= / =(:label-id ID :open t)= / =(:assignee-id ID :open t)= round-trip; =state.type.nin= with the exact open-state set (any order) maps to =:open t=, while =state.type.nin= with any other set refuses with a structured reason. +- *Copy-down refuses* with a structured reason naming the construct for =or= with multiple branches, surviving nested =and=/=or=, =neq=, generic =nin= (other than the open predicate), =null=, date comparators, =labels.every= / =labels.none=, label =parent=, and unknown dimensions — and never produces a lossy local view. The empty-filter (={}=) view refuses via the create guard. +- *Copy-down candidate builder* (fixture/integration) handles favorited Custom Views, duplicate titles, stale ids, =nil= / missing =filterData=, and GraphQL errors. +- *Account:* new local views get the active =:account=; edit preserves it. Run, edit, publish, publish-current-view, and delete all refuse on a cross-account entry; delete makes no =customViewDelete= call and publish makes no create/update call on mismatch. Tested for each. +- *Singular multi-value refusal:* =assignee.id.in=, =assignee.email.in=, =project.id.in=, =cycle.id.in=, =team.key.in=, and multi-id =labels.some.id.in= each refuse with a structured reason; the plural =labels.some.name.in= (=:labels=) and =state.type.in= (=:state-type=) accept multiple values. Tested. +- *Cross-store duplicates:* same-=CustomView=-id tracked-local-plus-favorite dedupes to the local entry; same-name different-id local and Linear views both show with qualified labels. Tested. +- *Tracked-local run/refresh:* =pearl-run-local-view= and a deduped =pearl-pick-source= selection run the local authoring filter (not =pearl--query-view-async=) even when the entry is tracked; a local-view buffer refreshes via its local filter, a native Linear-view buffer refreshes server-side. Tested, including the edited-but-unpublished case where local and Linear diverge. +- Publish (=pearl-publish-local-view=) creates a tracked Linear view on first publish and updates it in place on re-publish, unchanged from the current sync behavior. +- Full ERT suite green, =make lint= and byte-compile clean. + +* Implementation phases (commits) + +1. *Direct rename* — rename the defcustom and the commands to the local/Linear view vocabulary, remove the old =q= saved-query bindings, update Craig's config, reword docstrings. No aliases. No behavior change. (=refactor:=) +2. *Create + edit* — =pearl-create-local-view=, =pearl-edit-local-view=, the metadata-preserving =pearl--save-local-view= writer, the shared local name-collision prompt (Replace/Rename/Cancel), account stamping/preservation, builder extraction shared with =pearl-list-issues-filtered= (whose save offer rewords to "save this local view?"). Tests for metadata preservation, collisions, and account behavior. (=feat:=) +3. *Keymap + transient reshape* — the view noun under each verb; help labels. Tests for the keymap. (=feat:=) +4. *Picker labels + cross-store dedup + dispatch* — reword =pearl--pick-source-candidates= / scope-label; implement the same-id dedupe-to-local and same-name show-both rules; make a deduped tracked-local entry dispatch to the *local* filter (not the Linear branch); record =:type filter= source descriptors for local-view buffers. Tests. (=refactor:= / =feat:=) +5. *Publish (copy up)* — present the existing publish/sync as =pearl-publish-local-view=; transient + keymap slot; no behavior change. (=refactor:=) +6. *Save locally (copy down) — the reverse-compile* (read API verified, see *Verified API shape*) — =pearl--reverse-compile-issue-filter= (normalize the view-filter tree, then match against the dimension table), =pearl--issue-filter-representable-p= (structured refusal reasons), the favorited-Linear-view candidate builder, and =pearl-save-linear-view-locally= (fetch =filterData= for the chosen view, reverse-compile, save the fork through =pearl--save-local-view=), keymap + transient slot. Round-trip (including =:open t= and the favorite-derived =:open= shapes), normalize-from-real-view-filter, the open-predicate-=nin=-vs-generic-=nin= split, and refusal tests are the load-bearing coverage. (=feat:=) +7. *README + package-summary* — document filter / local view / Linear view, the git-style lifecycle, the publish / save-locally directions and the representability boundary, and the full create/edit/run/publish/save-locally/delete surface. No "query" in user-facing text. (=docs:=) + +* Implementation tasks (drop-in for todo.org) + +#+begin_src org +,** TODO [#B] local/Linear views — direct rename, no aliases :feature: +Rename the saved-query defcustom and existing commands directly to the local/Linear view vocabulary. No obsolete aliases; remove the old =q= saved-query bindings; update Craig's config in the same change. Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 1). + +,** TODO [#B] local/Linear views — create and edit :feature: +Add =pearl-create-local-view=, =pearl-edit-local-view=, the metadata-preserving =pearl--save-local-view= writer, local Replace/Rename/Cancel collision handling, account stamping/preservation, and shared builder internals with =pearl-list-issues-filtered= (save offer reworded to "save this local view?"). Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 2). + +,** TODO [#B] local/Linear views — keymap and transient :feature: +Expose the view noun under clean product vocabulary (local view / Linear view / filter / publish / save locally) in the keymap and transient. No "query" in user-facing labels. View-operation labels avoid "source"; the unified picker =pearl-pick-source= keeps "source" deliberately (it spans views, filters, and browser-only favorites). Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 3). + +,** TODO [#B] local/Linear views — picker labels + cross-store dedup :feature: +Reword the picker candidates and scope labels to =[local]= / =[local → Linear:SCOPE]= / =[linear]=, and implement the cross-store rule: dedupe same-=CustomView=-id tracked-local-plus-favorite to the local entry; show same-name different-id local and Linear views both. Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 4). + +,** TODO [#B] local/Linear views — publish local view :feature: +Present the existing publish/sync path as =pearl-publish-local-view=, preserving first-publish create and re-publish update-by-id behavior. Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 5). + +,** TODO [#B] local/Linear views — save Linear view locally :feature: +Implement normalize-then-match reverse compilation for readable =CustomView.filterData=, structured refusal reasons, favorited-Linear-view candidates, and forked local-view persistence through =pearl--save-local-view=, as =pearl-save-linear-view-locally=. Reverse-compile contract per the dimension table: distinct =:state= vs =:state-type=, =:open t= mapped from the exact open-state =nin= (generic =nin= refuses), priority canonicalized to numeric 0..4, singular id/scalar keys accept only =eq= or one-element =in=, plural keys (=:labels=, =:state-type=) explicitly whitelisted, and multi-value =in= on singular keys refuses. Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 6). + +,** TODO [#B] local/Linear views — docs :feature: +Update README and package summary with filter / local view / Linear view vocabulary, the publish / save-locally lifecycle, command surface, and representability boundary. No "query" in user-facing text. Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Implementation phases, phase 7). + +,** TODO [#B] local/Linear views — naming and behavior test surface :test: +Unit: direct renames without aliases, absence of user-facing "query" vocabulary (=pearl-pick-source= explicitly allowed, not banned), metadata-preserving edit, local collision policy, account ownership guard for run/edit/publish/publish-current/delete (delete makes no Linear call on mismatch), cross-store duplicate display/dedupe, tracked-local run/refresh dispatch (=pearl-run-local-view= runs the local filter, not =pearl--query-view-async=), picker labels, reverse-compile round trips and refusals (per-dimension, including =:state= vs =:state-type= with multi-type =in=, =:open t= exact-predicate success and generic-=nin= refusal, priority canonicalization and out-of-range refusal, singular multi-value =in= refusal for =assignee.id=/=assignee.email=/=project.id=/=cycle.id=/=team.key=/=label id=, and the plural =:labels= / =:state-type= exceptions), normalizer fixtures, structured refusals. Integration: copy-down candidate fixtures, stale ids, missing/nil =filterData=, GraphQL errors, publish update-by-id regression, delete does not call Linear on account mismatch, refresh re-runs local filter for a local-view buffer vs server-side for a native Linear-view buffer. E2e / manual-verify: create -> edit -> run local -> publish -> re-publish update -> save Linear view locally -> refuse unrepresentable view -> delete local/tracked views. Spec: [[file:docs/specs/local-and-linear-views-spec.org]] (Acceptance criteria). +#+end_src + +* Out of scope (vNext) + +- *All-Custom-Views copy-down* — enumerating every Custom View (not just favorited ones) via a paged =filterData= query, with its own cache path. v1 copies down favorited views only; this is the part that needs pagination. +- *Richer local authoring model* — OR-logic, nested =and=/=or=, and unmodeled dimensions (label =parent=, due date, estimate, creator). Each one added shrinks copy-down's refused set. Its own spec. +- *Broader negative operators* — generic =nin= / =neq= / =null= beyond Pearl's recognized =:open t= predicate. These need authoring-UI semantics (how a user builds a "not X" filter), not just parser support, so they stay vNext; v1 represents only the exact open-state predicate. +- *Plural authoring keys for the remaining singular dimensions* — =:assignee-ids=, =:assignees= (emails), =:project-ids=, =:cycle-ids=, =:team-keys=, and =:label-ids= — would let multi-value =in= Linear views ("assignee in A or B", "project in P1 or P2", multi-id labels) copy down instead of refusing. Each needs builder support for selecting several values; v1 keeps the singular keys and refuses the multi-value cases. (The named-states case shipped separately as the multi-state-filter spec: =:state= now accepts a list, so =state.name.in= copies down.) +- *Richer local filter editor* — exposing priority symbols, negative operators, and broader state logic as first-class authoring choices (rather than the canonical numeric priority and AND-only model v1 ships). +- *Unpublished-changes indicator* — a label like =[local → Linear:Engineering, unpublished]= when a tracked local view's local filter differs from its last-published Linear mirror, so the divergence window after edit (before publish) is visible. +- *Remote-drift detection before copy-up* — warn when the tracked Linear view changed on Linear's side before copy-up overwrites it (today copy-up updates in place unconditionally). +- Project / initiative scope on publish / copy-up (still team/personal only, per =saved-query-sync-spec.org=). +- Syncing =:sort= / =:order= to Linear. +- A cache inspection / clear path for copied-down view metadata, if view enumeration caching grows. + +(An opaque, run-only copy-down for non-representable views was considered and rejected — refuse-don't-guess, Agreed decision 8.) + +* Review dispositions + +The Codex review (=Needs research=) was strong and code-grounded; most findings were accepted as written. Only the modified ones and the resolved blocker are listed here; everything else — HP2 (metadata-preserving edit), HP4 (local collision policy), MP1 (primary vs legacy reverse shapes), MP2 (account behavior), MP3 (observability), and the architecture/test-strategy observations — was accepted as written and woven into the body. + +- *HP1 (verify =CustomView.filterData= read shape) — accepted, then resolved by probe, with a correction.* A live probe (2026-06-01) confirmed =filterData= is readable (=NON_NULL JSONObject=) but showed its shape is *not* the =IssueFilter= input Pearl emits — it is Linear's view-filter =and=/=or= tree (top-level =and=, single-branch =or= wrappers, =in=[single]= scalars). The reverse-compile was redesigned from a one-to-one inversion into normalize-then-match. This was the highest-value finding: it changed the design before implementation rather than at integration. +- *HP3 (close the open questions) — accepted.* All eight dispositioned with Craig and moved to *Agreed decisions*; none remain open. +- *HP5 (candidate source + pagination) — modified (scope).* The review offered all-Custom-Views (paged) or favorited-only. Chose favorited Linear views for v1: it is already bounded, reuses the existing favorites enumeration, and removes the pagination/cache complexity entirely. All-Custom-Views enumeration deferred to vNext, where the pagination it requires belongs. +- *Rubric — modified (scope of the hold).* The review held the whole spec at =Needs research= on HP1. Narrowed: only copy-down depended on the read-API question, and the probe cleared it, so the spec is =Ready= end to end rather than gated. Phases 1-5 never depended on the probe. + +Everything else in the first review was accepted as written. + +** Second review (naming contract, =Not ready=) + +A second Codex pass, grounded in Craig's clarification that Pearl has no users, held the spec at =Not ready= on the naming contract. Folded in: + +- *HP1 (drop backward-compat / aliases) — accepted.* With no users, the obsolete aliases protected nobody and kept "query" alive in completion, tests, and docs. Renames are now direct; the rename map's alias column is gone; the Migration, acceptance, and phase text drop the alias requirement; Craig's own config is the only thing updated. +- *HP2 (eliminate user-facing "query") — accepted.* "query" survives only in internal GraphQL helpers and historical text. Added a naming-regression acceptance criterion. +- *HP3 (intent-first command names) — accepted, with one modify.* Copy-up's command is =pearl-publish-local-view= (not =pearl-copy-view-to-linear=); copy-down's is =pearl-save-linear-view-locally=. *Modify:* the review wanted =pearl-pick-source= replaced with a view-oriented name; kept =pearl-pick-source= because it runs favorites, by-dimension filters, and browser-only favorites too — "source" is accurate and is the word =issue-sources-spec= established. Renaming it "run view" would misname it. +- *HP4 (keymap/transient/docs/tests around clean vocab) — accepted.* Since the commands are publish / save-locally, not "copy", they moved out of the =C-; L y= clipboard group into the fetch group; =y= keeps its clipboard meaning. +- *HP5 (cross-store duplicate display) — accepted.* Same-=CustomView=-id entries dedupe to the local tracked view; same-name different-id entries both show with qualified labels. New subsection + decision + tests. +- *MP1/MP2/MP3 — accepted.* Keep "filter"; =CustomView= at the API boundary only; stated the v1 performance bounds. + +The naming pivot reversed three first-pass decisions (alias-based rename, =pearl-copy-view-to-linear= primary, copy ops under =C-; L y=). Craig ruled on each before they were folded. + +** Third review (run/refresh semantics, =Not ready=) + +A third Codex pass confirmed the naming contract and surfaced one blocking behavioral ambiguity the response passes had left: a published local view has two filters (the local authoring plist and the last-published Linear mirror), and the spec never said which one runs. + +- *HP1 (run/refresh semantics) — accepted.* A sharp catch of a contradiction this responder introduced: the model says "local is the source of truth" and the dedup rule said selecting a tracked entry "lands you on the thing you can edit," but the current code dispatches synced entries through the Linear server-side branch. Resolved per the model: =pearl-run-local-view= and a deduped =pearl-pick-source= run the *local* filter even when tracked; =pearl-run-linear-view= runs the server-side mirror; local-view buffers are =:type filter= sources that refresh locally. This is the direct consequence of an already-agreed decision, so it was folded as the model-consistent resolution (new *Run and refresh semantics* subsection, decision 16) — flagged to Craig as the one behavior point to override. +- *MP1 (vocabulary contradiction) — accepted.* The task/test wording banned user-facing "source" while the spec deliberately keeps =pearl-pick-source=. Narrowed: ban "query"; view-operation labels avoid "source"; the unified picker keeps it. +- *MP2 (unpublished-changes indicator) — accepted to vNext.* +- Architecture/test observations (separate source descriptors for local vs Linear; dispatch and refresh tests) — accepted into the body. + +** Fourth review (=:open= / =nin= contradiction, =Not ready=) + +A fourth Codex pass found the last internal contradiction, again one this responder left: the copy-down acceptance criterion promised every =pearl--build-issue-filter= shape round-trips, but the refusal list blanket-refused =nin=, and Pearl's very common =:open t= compiles to =state.type.nin=["completed","canceled","duplicate"]=. Both can't hold. + +- *HP1 (=:open t= / =nin=) — accepted in full.* Verified against =pearl--compile-state-filter= (=:open t= → =state.type.nin= the open-state types; =:state= / =:state-type= take precedence) and the favorite-derived =(:project ID :open t)= shapes. Added =:open= as a first-class reverse-compile row matching exactly that =state.type.nin= set (order-insensitive); qualified the refusal so generic =nin= still refuses but the open predicate maps to =:open t=; added the precedence note, the round-trip and refusal acceptance criteria, the phase-6 fixture, and a vNext line for broader negation (which needs authoring UI, not just a parser). +- The review's open question — exact open predicate only, or broader =nin=? — answered: exact predicate only in v1, broader negation vNext. + +No other findings; the review confirmed naming, ownership, run/refresh, persistence, collision, and performance are resolved. + +** Fifth review (reverse-compile table precision, =Not ready=) + +A fifth Codex pass found three more table imprecisions where the "identical plist" round-trip promise outran Pearl's actual DSL. All three accepted in full (verified against =pearl--compile-state-filter=, =pearl--compile-priority=, and the label keys): + +- *HP1 (=:state= vs =:state-type=) — accepted.* The single =:state= table row conflated two distinct keys: =:state= → =state.name.eq=, =:state-type= → =state.type.in=. =state.type.in= can't round-trip to =:state=. Split into two rows; the reverse emits the matching key; precedence over =:open= preserved. +- *HP2 (=:priority= canonical form) — accepted.* =pearl--compile-priority= maps both =(:priority high)= and =(:priority 2)= to =priority.eq=2=, so the symbol-vs-int choice isn't recoverable. Defined canonical numeric output (0..4) with a documented-canonicalization carve-out in the round-trip promise; out-of-range refuses. +- *HP3 (multi-id labels) — accepted.* =:label-id= is singular and there's no =:label-ids= key, so single =labels.some.id= emits =:label-id= and multi-id =labels.some.id.in= refuses in v1 (plural key is vNext). =:labels= names stay representable. +- *MP1 (stale task block) — accepted.* Refreshed the copy-down and test-surface drop-in tasks to name the =:open=, state/state-type, priority, and label-id fixtures. + +The open question is answered by the minimal v1 rules the review proposed (state.type.in → =:state-type=; priority → numeric; multi-id labels refuse). No findings remain open. + +** Sixth review (singular multi-value =in= + account guard scope, =Not ready=) + +A sixth Codex pass generalized two patterns the spec had only half-stated. Both accepted in full: + +- *HP1 (multi-value =in= on singular keys) — accepted.* Last round fixed multi-id labels; the same gap applied to =:assignee-id=, legacy =:assignee= email, =:project=, =:cycle=, and =:team=. Replaced the label-specific rule with a general one: a singular key accepts only =eq= or a one-element =in=; multi-value =in= refuses unless the key is genuinely plural (=:labels=, =:state-type=). Generalized the boundary bullet, acceptance criteria, and tests accordingly. +- *HP2 (account guard scope) — accepted.* The spec stamped =:account= and guarded edit/copy, but not run, publish, publish-current, or delete. Verified the code gap (=pearl-sync-saved-query-to-linear= and =pearl-delete-saved-query= don't check the entry's =:account= today, only the buffer context). Defined one =pearl--require-local-view-account= guard over every read/mutate operation, with delete and publish refusing *before* any Linear call on mismatch; legacy/untagged entries stay unguarded. + +vNext gained the plural authoring keys (=:assignee-ids=, =:project-ids=, =:cycle-ids=, =:team-keys=, =:label-ids=). No findings remain open. + +* Review and iteration history + +** 2026-06-01 Mon @ 15:03:50 -0500 — Codex — reviewer + +- *What changed or was recommended:* Reviewed the spec against the implementation and related specs. Wrote a sibling review with a =Needs research= rubric, blocking findings for unverified =CustomView.filterData= read shape, metadata-preserving edit semantics, unresolved implementation-changing open questions, local name collision behavior, and copy-down candidate boundaries. +- *Why:* Copy-down and edit add new ownership/persistence paths beyond the shipped one-way sync. Without these decisions, implementation would have to invent API assumptions and data-loss behavior mid-stream. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 15:24:03 -0500 — Claude Code (pearl) — responder + +- *What changed:* Dispositioned every Codex finding. Accepted HP2/HP4/MP1/MP2/MP3 and the architecture/test observations into the body (metadata-preserving =pearl--save-local-view=, local Replace/Rename/Cancel collision policy, primary-vs-legacy reverse shapes, account stamping, copy-down progress messages, pure-helper decomposition). Modified HP5 (favorited-only candidate source for v1) and the rubric scope. Ran a live API probe to clear HP1 and corrected the =filterData= shape assumption, redesigning the reverse-compile as normalize-then-match. Closed all eight open questions into *Agreed decisions* with Craig. Status raised Draft → Ready. +- *Why:* Copy-down and edit are the first operations that reconstruct or preserve local authoring state, so they needed sharper contracts than the one-way publish path. The probe was the load-bearing step: the read shape was an assumption, and it was wrong in a way that mattered. +- *Artifacts:* Live probe against =https://api.linear.app/graphql= (=customViews{ nodes{ id name filterData } }= + =CustomView= introspection); seven real Custom Views inspected for filter shape. Review file deleted on completion. + +** 2026-06-01 Mon @ 15:30:53 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow after the response pass. Confirmed the prior blockers are resolved, assigned =Ready=, and added the required =Implementation tasks (drop-in for todo.org)= block from the phase plan. +- *Why:* The updated spec now has verified copy-down API shape, closed v1 decisions, explicit ownership/persistence semantics, safe local collision behavior, account behavior, bounded copy-down scope, acceptance criteria, and a phaseable implementation plan. +- *Artifacts:* No sibling review file needed; the spec is implementation-ready. + +** 2026-06-01 Mon @ 15:35:43 -0500 — Codex — reviewer + +- *What changed or was recommended:* Reopened review based on Craig's naming and collision/performance feedback. Wrote a new sibling review holding the spec at =Not ready= until it removes the no-longer-needed backward-compatibility layer, eliminates user-facing "query", promotes intent-first public command names (publish / save locally / run view), defines cross-store duplicate behavior for local-vs-Linear view names and same =CustomView= ids, states the v1 performance bounds, and updates phases/tests around the clean vocabulary. +- *Why:* Pearl has no users, so preserving old saved-query names and aliases creates confusion without protecting anyone. The spec's public API should match Linear users' vocabulary directly: filter for conditions, local view / Linear view for saved runnable things. The unified picker also needs deterministic behavior when the same title or same Linear id appears through both local and Linear stores. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 15:43:09 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the naming-contract review. Dropped the obsolete-alias / backward-compat layer entirely (direct renames, old =q= bindings removed, Craig's config updated in-change). Eliminated user-facing "query" and added a naming-regression acceptance criterion. Made command names intent-first: =pearl-publish-local-view= for copy-up, =pearl-save-linear-view-locally= for copy-down; moved them out of the =C-; L y= clipboard group into the fetch group. Added the cross-store duplicate-display policy (HP5) and the v1 performance-bounds note (MP3). Kept =pearl-pick-source= against HP3's rename suggestion (it runs more than views). Reconciled the three reversed first-pass decisions with Craig before folding. Rewrote the Agreed decisions (now 15), acceptance criteria, phases, and the drop-in task block around the clean vocabulary. Status restored to =Ready=. +- *Why:* With no users to protect, the alias layer was pure cost — it kept "query" alive in exactly the surfaces the rename meant to clean. Intent-first command names are the product's UX; symmetry ("copy up/copy down") matters less than the word a Linear user searches for. +- *Artifacts:* Review file deleted on completion. + +** 2026-06-01 Mon @ 15:59:16 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow against the updated spec. Wrote a new sibling review holding the spec at =Not ready= until tracked local view run/refresh semantics are explicit, especially whether a published local view with unpublished local edits runs the local filter or the linked Linear view. Also recommended aligning the "no source language" test/task wording with the decision to keep =pearl-pick-source=. +- *Why:* The spec now says the local view is the editable source of truth, while the current implementation runs synced entries through the Linear view branch. That answer changes picker dispatch, source descriptors, refresh behavior, and tests. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:03:12 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the run/refresh review. Added a *Run and refresh semantics* subsection and decision 16: the local view is the runtime source of truth, so =pearl-run-local-view= and a deduped =pearl-pick-source= run the local filter even for a tracked entry, =pearl-run-linear-view= runs the server-side mirror, and local-view buffers are =:type filter= sources that refresh locally. Tightened the cross-store dedup line to say the deduped entry runs local. Fixed the vocabulary contradiction (MP1): banned "query", not "source", so the kept =pearl-pick-source= is consistent. Added the unpublished-changes indicator to vNext (MP2). Updated acceptance criteria, phase 4, and the test surface with the dispatch/refresh semantics. +- *Why:* The responder had introduced the contradiction — the model claims local is the source of truth, but the inherited code runs tracked entries server-side. Resolving it toward local-runs-local is the only reading consistent with the model already agreed; it changes current behavior deliberately. Flagged to Craig as the one behavior point to confirm or override. +- *Artifacts:* Review file deleted on completion. + +** 2026-06-01 Mon @ 16:11:25 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow against the latest spec and wrote a focused =Not ready= review. The remaining blocker is the copy-down reverse-compile contract for =:open t=: current Pearl compiles =:open t= to =state.type.nin=["completed","canceled","duplicate"]=, while the spec both promises every Pearl-authored filter shape round-trips and says =nin= is unsupported/refused. Recommended adding an explicit =:open t= representability rule for that exact predicate, while continuing to refuse generic =nin=. +- *Why:* =:open t= is part of Pearl's current authoring model and appears in existing favorite-derived filters. Without this rule, implementation has to choose between rejecting common representable views or violating the round-trip acceptance criterion. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:21:51 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the =:open=/=nin= fix. Verified against =pearl--compile-state-filter= and the favorite-derived =:open t= shapes, then added =:open= as a first-class reverse-compile row (matching exactly =state.type.nin= of the open-state types, order-insensitive), qualified the refusal list so generic =nin= still refuses but the open predicate maps to =:open t=, added the precedence note, the =:open= round-trip and generic-=nin=-refusal acceptance criteria, the phase-6 fixture, and a vNext line for broader negation. Resolved the review's open question: exact open predicate only in v1. +- *Why:* The acceptance criterion (every Pearl shape round-trips) and the blanket =nin= refusal couldn't both hold, since Pearl's most common predicate compiles to =nin=. Representing exactly =:open t= and nothing broader keeps the round-trip true without opening general negation, which needs authoring UI. +- *Artifacts:* Read =pearl--compile-state-filter= / =pearl--filter-keys= to confirm the compiled shape. Review file deleted on completion. + +** 2026-06-01 Mon @ 16:26:07 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran a thorough final spec-review pass and wrote a sibling =Not ready= review focused on remaining reverse-compile contradictions. Findings: split =:state= from =:state-type= in the match table, define canonical numeric =:priority= reverse output because symbols and integers compile to the same =priority.eq= value, and specify that =labels.some.id.in= with more than one id refuses unless a plural id authoring key is added. Also noted the drop-in task block is stale after the =:open= fix. +- *Why:* Copy-down must produce editable local authoring plists. If the reverse compiler's output shape is not specified for these existing =pearl--build-issue-filter= forms, implementation has to invent product behavior and tests cannot assert a stable contract. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:33:11 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the three reverse-compile table fixes. Verified against =pearl--compile-state-filter=, =pearl--compile-priority=, and the label keys, then: split the =:state= row into =:state= (=state.name.eq=) and =:state-type= (=state.type.in=); defined canonical numeric =:priority= (0..4, with the documented-canonicalization carve-out in the round-trip promise, out-of-range refuses); restricted =:label-id= to a single id and added multi-id refusal. Updated the boundary refusal list, the acceptance criteria (per-key round-trip + refusal cases), the phase-6 contract, the drop-in task block (MP1), and vNext (plural =:label-ids=, richer editor). +- *Why:* The "identical plist" promise was stronger than Pearl's DSL allows: =state.type.in= maps to =:state-type= not =:state=, priority symbols and integers collapse to one number, and multi-id label filters have no authoring key. Naming the exact reverse output (and refusing what has no representation) keeps copy-down's editable-result guarantee honest. +- *Artifacts:* Read =pearl--compile-priority= and confirmed no =:label-ids= key. Review file deleted on completion. + +** 2026-06-01 Mon @ 16:42:12 -0500 — Codex — reviewer + +- *What changed or was recommended:* Restarted the requested two-clean-pass review loop; pass 1 was not clean. Wrote a sibling =Not ready= review with two blockers: add a general singular-vs-plural =in= rule so multi-value =in= on singular id fields (=assignee.id=, =project.id=, =cycle.id=, and singular label id) refuses unless the target key is explicitly plural; and expand account ownership guards from edit/copy to every local-view operation that reads or mutates an entry, especially publish and delete. +- *Why:* Copy-down must not silently narrow multi-value Linear filters to singular local keys, and account-tagged local views must not be run, published, or remotely deleted under the wrong Linear account. Both decisions affect implementation and tests. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:58:24 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded both blockers. Generalized the multi-id-label rule into a singular-vs-plural =in= rule covering =:assignee-id=, =:assignee= email, =:project=, =:cycle=, =:team=, and =:label-id= (multi-value =in= refuses; only =:labels= and =:state-type= take multiple), updating the reverse-compile section, boundary list, acceptance criteria, and tests. Expanded the account guard from edit/copy to one =pearl--require-local-view-account= over run/edit/publish/publish-current/delete/save-over, with delete and publish refusing before any Linear call on mismatch; verified the current code only guards buffer context, not the entry tag. Refreshed the drop-in task block and broadened the vNext plural-keys item. +- *Why:* Both were patterns the spec stated for one case (labels; edit/copy) but not the general one. Naming the full rule keeps copy-down from silently narrowing a view and keeps a remote delete/publish from firing against the wrong workspace. +- *Artifacts:* Confirmed =pearl-sync-saved-query-to-linear= / =pearl-delete-saved-query= guard only buffer/account context, not the entry's =:account=. Review file deleted on completion. + +** 2026-06-01 Mon @ 17:02:44 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow against the latest spec, including two clean contradiction passes after reading the implementation, related specs, acceptance criteria, implementation phases, and task block. Assigned =Ready= with no blocking findings. Corrected the stale status count from two to six incorporated review rounds. +- *Why:* The prior blockers are now resolved in the spec: user-facing vocabulary is Linear-aligned, local-vs-Linear run/refresh ownership is explicit, copy-down representability and refusal rules are precise, account ownership guards cover read and mutate operations, performance bounds are stated, and the test surface covers the risky behavior. +- *Artifacts:* No sibling review file needed; no project-local =todo.org= exists, and the spec already contains the drop-in implementation task block. diff --git a/docs/specs/modified-ticket-indicator-spec-review.org b/docs/specs/modified-ticket-indicator-spec-review.org new file mode 100644 index 0000000..a89041f --- /dev/null +++ b/docs/specs/modified-ticket-indicator-spec-review.org @@ -0,0 +1,110 @@ +#+TITLE: Review: pearl — Modified-Ticket Indicator Spec, Round 2 +#+AUTHOR: Codex +#+DATE: 2026-06-07 +#+STARTUP: showall + +* Scope reviewed + +- [[file:modified-ticket-indicator-spec.org][modified-ticket-indicator-spec.org]] after the round-1 response. +- Round-1 review dispositions folded into the spec. +- [[file:../pearl.el][pearl.el]] dirty scan, save lifecycle, comment ownership, overlay decoration, and mode-line lighter context. +- Existing task [[file:../todo.org][todo.org]] entry "Visual indicator for modified tickets". + +* Implementation-readiness + +Rubric: =Ready with caveats=. + +The round-1 blockers are resolved. The spec now commits to a concrete, strong UI: face + explicit non-mutating text marker + mode-line count; it separates pushable dirty comments from read-only/local-only edits; it defines buffer-local lifecycle state and recompute triggers; it corrects the current-code helper names; and it has a drop-in task block plus review history. + +The remaining caveats are small but should be cleaned up before calling the spec fully =Ready=: + +- The status still says =Revised= rather than an implementation-ready rubric. +- The =Open questions= section still asks Craig to confirm marker copy, even though defaults are declared non-blocking. +- The drop-in implementation tasks are all =[#C]= despite being the v1 implementation plan for a user-visible feature. The workflow says v1 implementation work should be =[#B]= unless intentionally not near-term. + +* Overall assessment + +This is now a strong spec. The key product risk from round 1, a too-subtle face-only indicator, is fixed. The design now gives three redundant signals: where the unsaved work is, what it means, and whether any pushable work remains anywhere in the buffer. That is the right shape for "clear, easy, and hard to miss" in an Org buffer. + +The comment-ownership model is also now aligned with Pearl's save model. The spec no longer lies by calling all changed comments "needs pushing"; it has pushable, local-only, and unknown states with distinct display semantics. + +* High-priority findings + +None. + +* Medium-priority findings + +** MP1: Promote the spec status to the actual review rubric + +Blocking: no. + +The body says the round-1 blockers are accepted and the implementation handoff is present, but the status still starts =Revised — Codex round 1 incorporated=. Change it to =Ready with caveats= now, or =Ready= after MP2/MP3 are resolved. + +** MP2: Close or disposition the remaining wording questions + +Blocking: no, if defaults are accepted. + +The spec says the marker-text choices have defaults and are not implementation blockers, but they still live under =Open questions (for Craig)=. That can make a future implementer pause. + +Recommended: move those two wording choices into =v1 decisions= as final defaults: + +- Pushable marker: =" unsaved"=. +- Local-only marker: =" local edit only"=. + +Keep the defcustoms so the wording remains adjustable without blocking implementation. + +** MP3: Use =[#B]= for the v1 implementation drop-in tasks, or state why this is deliberately =[#C]= + +Blocking: no. + +The current drop-in block uses =[#C]= for every implementation phase. The workflow's tracking rule is v1 work = =[#B]=, vNext/someday = =[#D]=. Since this spec is solving a real user-facing save-model gap and the existing todo entry is a live feature task, the phase tasks should probably be =[#B]=. If Craig intentionally wants this as optional dogfooding polish, keep =[#C]= but say so in the status or task intro. + +* UX observations + +The recommended default UI is now right: + +- Dirty pushable issue: face + trailing =unsaved= marker + mode-line count. +- Dirty own comment: comment marker plus parent issue bubble-up. +- Dirty read-only comment: separate =local edit only= cue, no count and no bubble-up. +- Unknown comment ownership: neutral state until viewer classification resolves. + +One implementation watchpoint is already correctly called out: trailing markers must coexist with org right-aligned tags. That should be tested against a heading with label tags. + +* Architecture observations + +The single =pearl--redecorate-buffer= entry point is the right abstraction. It reduces the risk of stale overlays as more display-only decorations accumulate. + +* Robustness and performance observations + +The lifecycle contract is now concrete enough: buffer-local count/cache/timer, change-scoped idle recompute, async post-save recompute from the actual dirty scan, full-pass redecorate after buffer rewrites, and viewer-callback liveness/account checks. + +* Test strategy recommendations + +The test surface is good. Add one explicit case for the tag coexistence caveat: a dirty issue heading with right-aligned org tags still shows the =unsaved= marker without corrupting the heading text or tags. + +* Documentation and tooling recommendations + +README should use the same vocabulary as the UI: =unsaved= for pushable work and =local edit only= for read-only comment drift. + +* Suggested spec edits + +- Update status to =Ready with caveats= or =Ready=. +- Finalize marker wording defaults and move them out of open questions. +- Change v1 task priorities to =[#B]=, or explicitly justify =[#C]=. +- Add tag-coexistence to the test surface. + +* Agreed decisions + +None reached during this review. + +* Open questions + +None that block implementation if the declared defaults are accepted. + +* vNext candidates + +Already captured: optional leading glyph/fringe, next/previous modified navigation, header-line/side-window summary, and save-all prompt vocabulary alignment. + +* Implementation tasks (drop-in for todo.org) + +Use the spec's drop-in block after adjusting the task priorities per MP3. diff --git a/docs/specs/modified-ticket-indicator-spec.org b/docs/specs/modified-ticket-indicator-spec.org new file mode 100644 index 0000000..1ea337c --- /dev/null +++ b/docs/specs/modified-ticket-indicator-spec.org @@ -0,0 +1,242 @@ +#+TITLE: pearl — Modified-Ticket Indicator Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-07 +#+STARTUP: showall + +* Status + +*Ready — visual model revised to a field-region highlight (Craig, 2026-06-07; Codex rounds 1-2 incorporated).* A brainstorm-and-pick spec, like the task asked: survey the ways to surface "this ticket has unsaved local edits," weigh them against what the buffer can drive cheaply, and commit to a v1 contract. Companion to [[file:ticket-save-model-v2-spec.org][ticket-save-model-v2-spec.org]], which created the gap this closes: edits defer to =pearl-save-issue= / =pearl-save-all=, so a ticket can carry unsaved changes with nothing on screen saying so. + +Round 1 (Codex, rubric =Not ready=) closed five blocking gaps: it caught that the spec equated "changed comment" with "needs pushing" (a non-own comment is local drift =pearl-save-*= skips, not pushes); it corrected three wrong helper names in Current state; it pushed the default cue from face-only to face + explicit text + count; and it asked for a real handoff (v1 decisions, drop-in tasks). All accepted. Round 2 (rubric =Ready with caveats=, no blockers) cleaned up the handoff: the marker-wording defaults are now final in [[*v1 decisions]] (defcustoms keep them adjustable), the tag-coexistence test is explicit, and the drop-in task priorities are dispositioned. Dispositions for both rounds in [[*Review dispositions]] below; the six round-0 open decisions are resolved in [[*v1 decisions]]. + +* Problem + +Under the deferred save model an issue (title, description, state, priority, assignee, labels) and its own comments can all carry unsaved local edits, and the only signal that an edit happened is the buffer being modified — a single dot in the mode line that says nothing about *which* ticket changed or that the change still needs pushing. At the point of the edit there is no cue at all. A user edits three tickets across a long buffer, runs =pearl-save-all=, and has no way to see beforehand what was dirty or to confirm afterward that it cleared. + +Two sharpenings from dogfooding (Craig, 2026-05-26): the cue must cover an edited *comment*, not just an issue, and it must read specifically as "unsaved — needs pushing," not the generic "buffer modified." + +* Current state + +The detector already exists and is the same one merge protection and save-all use, so the cue can ride it with no new dirty logic: + +- =pearl--issue-dirty-fields= (pearl.el:5914) returns, for the issue subtree at point and with no network, a plist of booleans (=:title= =:description= =:priority= =:state= =:assignee= =:labels=) plus =:comment-candidates=, the changed-comment list from =pearl--changed-comment-candidates=. Each field compares a live value against its synced baseline (drawer SHA / id properties). +- =pearl--issue-has-dirty-fields-p= (pearl.el:6493) collapses that plist to a single boolean. +- =pearl--scan-all-dirty= (pearl.el:6565) is the existing full-buffer scan: it walks every issue subtree and returns markers paired with their dirty plists. =pearl-save-all= drives off it. +- =pearl--subtree-has-local-edits-p= (pearl.el:6699) is the merge-protection predicate, wrapping =pearl--issue-dirty-fields= for one subtree. +- =pearl--changed-comment-candidates= yields per-comment markers; =pearl--classify-comment-candidates= splits them into =:own= (editable, pushable) and =:read-only= (edited locally, not pushable) *once a viewer id is known*. A candidate alone only means the body differs — ownership, and therefore pushability, needs the viewer. +- =pearl--viewer-async= (pearl.el:7813) resolves and caches the viewer id; =pearl-save-issue= / =pearl-save-all= call it only when comments are dirty. + +The overlay and mode-line machinery the cue should reuse is also already in place, and the overlays leave the buffer text untouched so parsing, save, and merge keep working: + +- =pearl--apply-heading-glyphs= (pearl.el:7890) rides a =display= overlay on the space after a heading's leading stars, ahead of the TODO keyword, leaving the stars (and any org-superstar / org-modern bullet) alone. +- =pearl--apply-comment-highlights= (pearl.el:7924), driven by the public =pearl-highlight-comments= (pearl.el:7956), puts a *face* overlay on a comment heading's text (starting after the stars, so org's own fontification on the stars is preserved) and =pearl-readonly-comment= over a read-only comment subtree. +- =pearl--hide-preamble= (pearl.el:4255) overlays the source header. All these are idempotent: each clears its own tagged overlays before reapplying, and runs after a render/refresh. +- =pearl--mode-line-lighter= (pearl.el:654) builds the =pearl-mode= lighter, currently =Pearl= or =Pearl[account]=. The aggregate count extends this, not a new global. + +The save and merge lifecycle the cue must track: + +- A successful field save advances the subtree's baselines (writes the SHA / =-SYNCED= id properties), so the next dirty scan sees that field as clean. The cue for a saved field must clear. +- =pearl-save-issue= / =pearl-save-all= are async and can partially push, skip (read-only / viewer-unavailable comments), conflict, or fail. The cue must recompute from the dirty scan *after the save queue completes and baselines are advanced*, not from the outcome status — a conflicted or failed field stays dirty and must stay marked. +- The merge refresh keeps unsaved edits on locally-edited subtrees (that is its whole point). The cue must persist on those kept subtrees after a merge, and must not appear on freshly-fetched clean ones. + +* Candidate cues (the brainstorm) + +The task listed five candidates. Measured against four constraints — (a) drivable cheaply from the existing dirty scan, (b) doesn't fight org's own fontification, (c) covers comments as well as issues, (d) reads as "unsaved, needs pushing" — they sort into two groups. + +*Rejected: anything that mutates the buffer.* + +- *Org tag* (e.g. =:unsaved:= on the heading) — writing the tag changes the subtree text, which the dirty scan hashes. The indicator would feed back into the very thing it measures: adding the tag makes the subtree differ from its baseline, and on save the tag is part of what gets compared or pushed. Self-referential and fragile. Reject. +- *Drawer flag* (e.g. a =:LINEAR-DIRTY:= property) — same feedback problem, same reason. The drawer is part of the subtree the scan hashes, and a written flag is buffer state that has to be cleaned up and kept out of the saved payload. Reject. + +Both also duplicate state that the dirty scan already computes on demand; the cue should *render* dirtiness, never *store* it. + +*Viable: overlays and the mode line, which never touch buffer text.* The field-region highlight and the count below are overlay/mode-line constructs. The v1 cue is a background highlight over the *region of the field that changed* — the title text, the description body, a comment, or the tag — which answers "which ticket" and "what in it" at once, plus an aggregate mode-line count for work scrolled off-screen (see [[*v1 decisions]]). A leading display glyph and a fringe bitmap were considered and deferred to vNext (the leading-stars slot already carries the ticket glyph + TODO keyword; the fringe is narrow and hard to theme). + +* v1 decisions + +1. *Cue = a background highlight over the changed field's region.* One face, =pearl-modified-highlight=, painted over the buffer region of whatever field is dirty — the title text, the description body, a comment, or the tag. It answers "which ticket" and "what changed in it" in one stroke, and being a background it leaves the field's own text untouched and readable. This replaces round 1/2's heading-face + trailing-"unsaved"-word design (rationale in [[*Review dispositions]]): highlighting the actual changed field is more informative than a word on the heading, and the explicit "unsaved" wording moves to the mode line. +2. *The heading is always highlighted for a pushable-dirty ticket*, on top of the specific changed field. That's the fold-visibility / bubble-up channel: a collapsed ticket still shows the heading highlight, and expanding reveals which field is lit. A title change is just the case where the changed field *is* the heading. +3. *Highlight face inherits =diff-changed=* ( =:extend t= ). A cross-theme legibility survey (modus variants + dupre) showed =diff-changed= stays readable as a background everywhere measured (contrast ~5.5–16), while =diff-refine-changed= breaks in some themes (dupre renders it white-on-gold, ~1.35). See [[*Faces and customization]]. +4. *Mode-line count ships in v1*, appended to the existing lighter: =Pearl[work] 3 unsaved=. *N counts tickets, not fields* — the number of issues with pushable dirty work, each counted once no matter how many of its fields or own comments changed. The ticket is the unit you act on (=pearl-save-issue= saves a ticket, navigation jumps between tickets), and "3 unsaved" reads as 3 items; a field count would need "7 unsaved changes" to not mislead. The format is a defcustom, so the bare number can become "3 tickets unsaved" if it reads ambiguously. +5. *Comment ownership decides which highlight* (Codex HP2). Three classes: + - *Pushable-dirty* = any dirty issue field, plus a dirty comment whose author id equals the resolved viewer id. Gets =pearl-modified-highlight=, is counted, and lights the parent heading (bubble-up). + - *Local-only-dirty* = a dirty non-own (bot / external) comment. =pearl-save-*= never pushes it, so it gets a *distinct muted* highlight ( =pearl-modified-local= ), is *not* counted, and does *not* bubble up. + - *Unknown* = a changed comment seen before the viewer resolves. Gets a neutral =pearl-modified-unknown= highlight, excluded from the count, until the async viewer classifies it. +6. *Trigger = change-scoped after-change + idle debounce* for live feedback, *plus* a full-pass redecorate after render / merge / open / sort / regroup / =pearl-mode= enable, *plus* a post-save recompute after the save queue completes. Details in [[*Refresh lifecycle and buffer-local state]]. +7. *Field-to-region map* — the buffer region each dirty field highlights: + - title -> the title text on the heading line + - description -> the description body region + - a comment -> that comment's heading + body + - labels -> the tag run ( =:tag:tag:= ) on the heading line + - state -> the TODO keyword + - priority -> the =[#x]= cookie + - assignee -> the =LINEAR-ASSIGNEE= drawer line, or the heading when the drawer is folded + - any pushable-dirty ticket -> its heading line as well (decision 2) +8. *Optional leading glyph and fringe marker are vNext*, not part of the v1 cue. + +* Proposed design + +Driven entirely by the existing dirty scan plus viewer classification; no new dirty logic, no buffer mutation. + +** The highlight, per field + +For each pushable-dirty ticket, =pearl-modified-highlight= is painted over its heading line (fold-visible) and over each changed field's region per the [[*v1 decisions]] map: title text, description body, the tag run, the TODO keyword, the =[#x]= cookie, or the assignee drawer line. A dirty own comment highlights that comment's region and lights the parent heading. The highlight is background-only, so the field's text keeps its own color. + +Comment classes use distinct faces: own -> =pearl-modified-highlight=; read-only (non-own) -> =pearl-modified-local= (muted, not counted); pre-viewer -> =pearl-modified-unknown= (neutral, not counted), until the async viewer resolves it. + +** The count, in the mode line + +=pearl--buffer-dirty-issue-count= returns the number of *tickets* with pushable dirty work (dirty fields or own dirty comments), each ticket once. =pearl--mode-line-lighter= appends " N unsaved" when N > 0, preserving the account name. The count is cached buffer-locally and refreshed by the redecorate pass, so it never recomputes during mode-line redisplay; a change calls =force-mode-line-update=. + +** One decoration entry point (Codex architecture obs) + +Pearl already calls =pearl-highlight-comments= after many render/merge paths; adding another independently-called applier multiplies stale-overlay bugs. v1 introduces a single =pearl--redecorate-buffer= that runs, in order: =pearl--hide-preamble=, =pearl--apply-heading-glyphs=, comment editability highlighting (when the viewer is available), and the modified-highlight applier. Every full-pass caller (render, merge, sort, regroup, open, =pearl-mode= enable) calls this one helper instead of remembering separate appliers. + +* Refresh lifecycle and buffer-local state + +The cue appears at the point of edit, clears on save, and survives a merge. Concrete contracts (Codex HP3): + +- *Buffer-local state:* the dirty-count cache, the set of pending changed ranges/markers, the debounce idle timer, and the overlay tag (=pearl-modified=) for clean removal. +- *On edit (appear).* =after-change-functions= records the changed region; an idle timer (debounced ~0.3–0.5s) recomputes the cue for the issue subtree(s) intersecting that region only — one subtree per edit, not the whole buffer, and never inside font-lock. +- *On save (clear).* After the (async) save queue completes, recompute the affected subtree(s) from the dirty scan with baselines advanced — not from outcome status, so a conflicted/failed/skipped field that is still dirty stays marked. +- *On merge / refresh / sort / regroup / open / mode enable (full pass).* Any operation that advances baselines or moves subtrees schedules a full =pearl--redecorate-buffer=. Because the applier reads the live scan, kept (locally-edited) subtrees keep their cue and fresh clean ones get none — no special-casing. +- *Timer and callback cleanup.* Cancel the idle timer when =pearl-mode= is disabled or the buffer is killed. Any =pearl--viewer-async= callback used for comment classification must check =buffer-live-p= and that it still belongs to the same account context before applying overlays (account switch can change ownership). +- *Mode-line.* Update the cached count in the redecorate pass and call =force-mode-line-update= when it changes. + +* Performance + +The per-issue scan hashes the subtree (=secure-hash 'sha256=) and reads a handful of drawer properties. A full-buffer pass is O(issues × subtree size); the change-scoped trigger holds steady-state cost to one subtree per edit, and the full pass runs only where the buffer is already being walked (render / merge / sort / regroup / open). Do *not* run viewer lookup on every idle tick: use the cached viewer id when present; if absent and comment candidates exist, schedule one async classification pass and show the unknown cue until it returns. The mode-line count is served from the cache, not recomputed on redisplay. + +* Faces and customization + +- =pearl-modified-highlight= — the highlight over a pushable-dirty field (and the heading). Default =((t :inherit diff-changed :extend t))=. =diff-changed= was chosen over =diff-refine-changed= after a cross-theme legibility survey: as a background under the theme's own text, =diff-changed= stayed readable everywhere measured (contrast ~5.5 in dupre, ~13–16 across the modus variants), while =diff-refine-changed= is a bright word-level-emphasis face that breaks in some themes (dupre renders it white-on-gold, ~1.35). Background-only, so the field's text keeps its color. Caveat for the public package: a theme that leaves =diff-changed= unstyled yields no visible highlight — ship a fallback background or document the dependency; for dupre + modus it's styled and legible. (A handoff asked the emacs.d project to bring dupre's diff faces in line with modus.) +- =pearl-modified-local= — muted highlight for a read-only (non-own) dirty comment, visibly weaker than =pearl-modified-highlight= so "can't push this" reads differently from "unsaved". Applied *after* the editability overlay with explicit overlay priority so a theme can't hide it under the comment overlay (Codex MP2). +- =pearl-modified-unknown= — neutral highlight for a pre-viewer changed comment. +- =pearl-show-modified-indicator= (defcustom, default t) — master toggle; nil removes all highlights and the count. +- =pearl-modified-mode-line-format= (defcustom, default " %d unsaved") — the mode-line segment, so "3 unsaved" can become "3 tickets unsaved". +- =pearl-modified-glyph= (defcustom, default "") — vNext optional leading glyph; off by default. + +* Edge cases + +- *Glyphs off* (=pearl-show-glyphs= nil) — the field highlight and the count still work; only the vNext leading glyph is suppressed. +- *Legacy buffer with no baselines* — a field with a missing baseline is *not* dirty (the existing scan's behavior), so an old file shows no false cue; the next refresh writes baselines. +- *Folded subtree* — the heading is highlighted for any pushable-dirty ticket (decision 2), so a folded dirty issue still shows the cue; expanding reveals the specific highlighted field, including a dirty own comment inside. +- *Group / non-issue headings* — group headings, the =Comments= container, and the help/preamble carry no =LINEAR-ID=; the applier skips them, like save and merge do. +- *Account switch* — invalidates viewer-derived comment classification; a full redecorate runs and pending viewer callbacks are dropped if the context changed. +- *Partial / failed / conflicted save* — fields still dirty after the queue stay marked; only cleared fields lose the cue. +- *Whitespace-only / round-trip edits* — the cue exactly mirrors the dirty scan, so anything the scan treats as clean shows no cue. + +* Implementation phases + +1. =pearl--buffer-dirty-issue-count= (counts tickets with pushable dirty work, each once; viewer-aware — dirty fields always, own comments once the viewer resolves, read-only/unknown excluded) + the mode-line segment extending =pearl--mode-line-lighter=, gated on =pearl-show-modified-indicator=. Cheapest standalone slice. +2. =pearl-modified-highlight= face + the field-region applier (modeled on =pearl--apply-comment-highlights=) with the field-to-region map, heading bubble-up, and own-comment coverage, folded into the new =pearl--redecorate-buffer= entry point and every full-pass caller. +3. The change-scoped after-change idle trigger + buffer-local state + post-save recompute + timer/callback cleanup, so the highlight is live during editing and clears on save. +4. The read-only =pearl-modified-local= / unknown =pearl-modified-unknown= comment highlights and the async viewer classification pass. + +* Test surface + +- =pearl--buffer-dirty-issue-count=: counts *tickets* (each once) with dirty fields or own dirty comments; a ticket with several dirty fields still counts one; excludes read-only and unknown comments; behaves per spec when the viewer is unavailable. +- Mode-line: the lighter appends the count while preserving the account name; nil toggle removes it. +- Field-region applier: a dirty title highlights the title text; a dirty description highlights the description body; dirty labels highlight the tag run; a clean field gets no highlight; applying twice leaves exactly one =pearl-modified-highlight= overlay per region (idempotence). +- Heading bubble-up: any pushable-dirty ticket has its heading highlighted; a folded ticket with only a dirty own comment still shows the heading highlight. +- Comments: a dirty own comment highlights the comment region and bubbles to the parent issue; the =Comments= container is unmarked; a read-only dirty comment gets the muted local-only highlight and does not bubble or count. +- Save: completion clears the highlight for fields whose baselines advanced; conflicted/failed/skipped-still-dirty fields stay highlighted. +- Merge: keeps the highlight on retained dirty subtrees and does not mark freshly-fetched clean ones. +- Trigger: an edit scopes recompute to the changed subtree; the idle timer is cancelled on a killed buffer / disabled mode. +- Tag coexistence: a dirty issue heading carrying right-aligned org tags (labels-as-org-tags) still highlights the tag run without corrupting the heading text or the tags. +- =pearl-show-modified-indicator= nil removes highlights and the count; glyphs disabled leaves the highlight + count active. + +* Implementation tasks (drop-in for todo.org) + +Priorities are =[#B]=: Craig bumped the parent task and these phases from =[#C]= to =[#B]= (round-2 MP3), so this is near-term v1 work, not deferred polish. + +#+begin_src org +** TODO [#B] Modified-ticket indicator: mode-line count + dirty classifier :feature: +Viewer-aware pushable-dirty count (=pearl--buffer-dirty-issue-count=), appended to =pearl--mode-line-lighter= as "N unsaved" while preserving =Pearl[account]=, cached buffer-locally with =force-mode-line-update=. Spec: [[file:docs/specs/modified-ticket-indicator-spec.org]] (phase 1). + +** TODO [#B] Modified-ticket indicator: field-region highlight overlays :feature: +=pearl-modified-highlight= (inherits =diff-changed=) painted over the changed field's region per the field-to-region map, with heading bubble-up and own-comment coverage, behind a single =pearl--redecorate-buffer= entry point. Spec: [[file:docs/specs/modified-ticket-indicator-spec.org]] (phase 2). + +** TODO [#B] Modified-ticket indicator: live recompute lifecycle :feature: +Buffer-local after-change idle debounce, post-save recompute (read the scan, not the outcome), full-pass redecorate after render/merge/sort/regroup/open/mode-enable, timer + viewer-callback cleanup on disabled/killed buffers. Spec: [[file:docs/specs/modified-ticket-indicator-spec.org]] (phase 3). + +** TODO [#B] Modified-ticket indicator: read-only + unknown comment treatment :feature: +=pearl-modified-local= (muted highlight) for non-own dirty comments and =pearl-modified-unknown= for pre-viewer ones, via an async viewer classification pass; excluded from the count and bubble-up. Spec: [[file:docs/specs/modified-ticket-indicator-spec.org]] (phase 4). + +** TODO [#B] Modified-ticket indicator test surface :test: +Count semantics, mode-line account integration, overlay idempotence, own-comment bubble-up, read-only/unknown behavior, save-clear vs conflict-persist, merge persistence, idle-trigger cleanup, customization toggles. Spec: [[file:docs/specs/modified-ticket-indicator-spec.org]] (Test surface). +#+end_src + +* Open questions (for Craig) + +None block implementation. The cue is now a field-region highlight (no per-heading marker word), the count is tickets, and the highlight face inherits =diff-changed= — all settled in [[*v1 decisions]] and overridable via defcustom. One non-blocking call remains: whether dirty-ticket navigation (=pearl-next-modified-ticket= / =pearl-previous-modified-ticket=) is worth pulling into v1 — currently parked in [[*vNext candidates]]. + +* vNext candidates + +- Optional leading glyph / fringe marker for visual redundancy beyond text + face + count. +- =pearl-next-modified-ticket= / =pearl-previous-modified-ticket= navigation, so a "7 unsaved" mode line is walkable before =pearl-save-all=. +- Header-line or side-window summary of dirty tickets if the mode-line count proves too subtle. +- =pearl-save-all= reusing the exact count vocabulary so its prompt count matches the mode line. + +* Review dispositions + +** Round 1 (Codex, 2026-06-07) — all blocking findings accepted + +- *HP1 (finalize v1 contract):* accepted. The six open decisions are resolved in [[*v1 decisions]]; two wording items are flagged for Craig rather than left open. +- *HP2 (resolve comment ownership before "needs pushing"):* accepted — the central fix. The draft's contradiction (mark every changed comment, yet exclude read-only from the count) is replaced by the three-class pushable / local-only / unknown contract. The cue now mirrors what =pearl-save-*= actually pushes. +- *HP3 (precise update lifecycle):* accepted. Added buffer-local state, post-save recompute from the scan (not outcome), viewer-callback liveness/context checks, full-pass triggers, and =force-mode-line-update=. +- *HP4 (correct helper names):* accepted — these were real errors. =pearl-buffer-has-dirty-issues-p= and =pearl--apply-preamble= don't exist; Current state now cites =pearl--scan-all-dirty=, =pearl--subtree-has-local-edits-p=, =pearl-highlight-comments= / =pearl--apply-comment-highlights=, =pearl--hide-preamble=, and =pearl--mode-line-lighter=, with =pearl--buffer-dirty-issue-count= introduced as new. +- *HP5 (handoff section):* accepted. Added v1 decisions, the drop-in todo block, and this dispositions + history record. +- *MP1–MP4:* accepted. Save-all count-vocabulary consistency noted (vNext for the prompt itself); overlay precedence + face composition specified (MP2); mode-line extends =pearl--mode-line-lighter= (MP3); the face requires a non-color attribute (MP4). +- *Architecture / perf / tests / docs observations:* accepted — single =pearl--redecorate-buffer= entry point, viewer-id caching with one async classification pass, the expanded test surface, and the README unsaved-vs-local distinction. +- *Refinements added beyond the review:* the trailing-marker-vs-org-tags coexistence caveat (labels-as-org-tags put tags at the heading-line end), and the explicit "unknown until viewer resolves" comment state as the viewer-unavailable default. + +** Round 2 (Codex, 2026-06-07) — rubric =Ready with caveats=, no blockers + +- *MP1 (promote status):* accepted. Status is now =Ready with caveats=. +- *MP2 (finalize wording):* accepted. The two marker defaults (" unsaved", " local edit only") moved into [[*v1 decisions]] as final, with the defcustoms kept so they stay adjustable; the wording questions are out of Open questions. +- *MP3 (task priority):* accepted. Craig bumped the parent task and the drop-in phases from =[#C]= to =[#B]=, confirming this as near-term v1 work. +- *Test / docs:* accepted. Added the explicit tag-coexistence test case; the README unsaved-vs-local vocabulary note was already captured in round 1. + +** Post-round-2 design revision (Craig, 2026-06-07) + +After the spec went =Ready=, Craig reworked the visual model in conversation, which supersedes the round-1/2 cue decisions: + +- *Cue is now a field-region highlight, not a heading face + trailing word.* The highlight is painted over the region of the field that changed (title / description / comment / tag), plus the heading for fold-visibility. This deliberately overrides Codex's round-1 push for an explicit per-heading "unsaved" word: highlighting the actual changed field says more about *what* changed, and the explicit "unsaved" wording now lives in the mode-line count. The strikethrough / ghosted-deletion idea raised in the same discussion was dropped — a label change just highlights the new tag (present text), so there's no removed-text to render. +- *Highlight face inherits =diff-changed=, not =diff-refine-changed=.* Chosen after a cross-theme legibility survey (modus variants + dupre): =diff-changed= stays readable as a background everywhere measured; =diff-refine-changed= breaks in dupre (white-on-gold, contrast ~1.35). Dupre's broken diff faces were handed to the emacs.d project to fix along modus lines. +- *Count is tickets, not fields.* "N unsaved" = tickets with pushable dirty work, each once — the ticket is the unit you save and navigate. + +* Review and iteration history + +** 2026-06-07 Sunday @ 01:00:57 -0500 — Codex — reviewer + +- *What changed or was recommended:* Ran the spec-review workflow. Rubric =Not ready=. Recommended making the default cue face + explicit non-mutating heading text + mode-line count; resolving comment ownership/read-only semantics; defining the live recompute lifecycle; correcting current-state helper names; and adding the drop-in task block. +- *Why:* The spec explores the right solution space but still leaves user-visible behavior open, and a face-only default is too subtle for "unsaved — needs pushing" to be clear and hard to miss. +- *Artifacts:* [[file:modified-ticket-indicator-spec-review.org][modified-ticket-indicator-spec-review.org]]. + +** 2026-06-07 Sunday — Craig + Claude — spec-response + +- *What changed:* Folded round 1 into the spec. Resolved the six open decisions into [[*v1 decisions]]; rewrote comment handling around the pushable / local-only / unknown contract; added the lifecycle/state contract, the single =pearl--redecorate-buffer= entry point, the corrected helper names, the drop-in todo block, and the review dispositions. Two marker-wording items left for Craig. +- *Why:* Move the spec from design exploration to an implementation-ready handoff per the review. + +** 2026-06-07 Sunday @ 01:49:17 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow after the round-1 response. Rubric =Ready with caveats=. No high-priority blockers remain. Recommended promoting the status/rubric, finalizing or dispositioning the marker-copy defaults, using =[#B]= for the v1 drop-in tasks or explicitly justifying =[#C]=, and adding a tag-coexistence test case. +- *Why:* The core UI and lifecycle contracts are now implementable; the remaining issues are handoff and polish rather than product-behavior blockers. +- *Artifacts:* [[file:modified-ticket-indicator-spec-review.org][modified-ticket-indicator-spec-review.org]]. + +** 2026-06-07 Sunday — Craig + Claude — spec-response (round 2) + +- *What changed:* Folded round 2 in. Status promoted to =Ready with caveats=; the marker-wording defaults finalized in [[*v1 decisions]] and dropped from Open questions; the tag-coexistence test case added; the drop-in task priorities dispositioned (kept =[#C]= to match the parent, with a justification and a noted =[#B]= bump option). +- *Why:* Clear the round-2 caveats so the spec is an implementation-ready handoff. + +** 2026-06-07 Sunday — Craig + Claude — design revision + priority bump + +- *What changed:* Craig bumped the parent + phases to =[#B]=. Then reworked the visual model: the cue is a field-region background highlight (over the changed title / description / comment / tag, plus the heading for fold-visibility) inheriting =diff-changed=, replacing the heading-face + trailing-"unsaved"-word design; the mode-line count is defined as tickets, not fields. Backed by a cross-theme legibility survey that picked =diff-changed= over =diff-refine-changed= and surfaced a dupre legibility bug (handed to the emacs.d project). +- *Why:* Craig wanted the highlight to show exactly what changed in a ticket, with theme-derived coloring, after seeing the survey. + +** 2026-06-07 Sunday @ 05:11:50 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow after the round-2 response. Rubric =Ready with caveats=. No new findings. Confirmed the marker wording is final, the tag-coexistence test is present, the =[#C]= implementation priority is explicitly justified against the parent task, and no implementation-blocking ambiguity remains. +- *Why:* The prior caveats are either resolved or intentionally dispositioned, so implementation can proceed under the spec's stated priority. +- *Artifacts:* this spec; no new review file was written because there are no new blocking review notes. diff --git a/docs/specs/multi-account-spec.org b/docs/specs/multi-account-spec.org new file mode 100644 index 0000000..f60adb1 --- /dev/null +++ b/docs/specs/multi-account-spec.org @@ -0,0 +1,202 @@ +#+TITLE: pearl — Multi-Account Support Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*Reviews incorporated through round 3; rubric =Ready* (Craig, 2026-05-25).* The original "resolve globals on switch, downstream oblivious" model leaked across accounts in an async codebase; this spec replaces it with an explicit account-context layer — dispatch-time snapshots, an implementation-boundary rule for where context is captured/applied, per-buffer =#+LINEAR-ACCOUNT= ownership with wrong/unmarked-buffer guards, a runtime (not persisted) active-account, a mode-line indicator, and an exact startup rule. The five safety calls (finish-into-snapshot, wrong-buffer refuse, unmarked-mutation refuse, saved-query =:account= guard, optional =:url=) are *adopted as final v1 decisions* — each the safer default and round-3-endorsed. Modified recommendations are in Review Dispositions. + +Lets one Emacs talk to more than one Linear workspace — a work account and a personal account — and switch between them safely. Raised by Craig: "I have a work account and a personal account. I would like to choose which account I'm working on easily and switch back and forth between the two fairly straightforwardly." + +* Problem + +Everything that identifies a workspace is a single global value today: + +- =pearl-api-key= — one key. +- =pearl-graphql-url= — one endpoint (always the same for Linear, but conceptually per-account). +- =pearl-org-file-path= — one active file. +- =pearl-default-team-id= — one team. +- the lookup caches (=pearl--cache-teams=, =-states=, =-team-collections=, =-views=, =-viewer=) — global, and *workspace-specific*: a personal account's teams and a work account's teams must never bleed together. + +To work the other account you'd re-customize the key (and team, and file) by hand and clear the cache. There's no notion of "which account am I on," and — the danger the reviewer surfaced — nothing stops a work command from running under personal credentials or a work fetch from landing in the personal file. + +* Current state + +- =pearl-api-key= is a plaintext defcustom, optionally loaded from =LINEAR_API_KEY=. =pearl--headers= reads it directly and errors when unset. +- =pearl-org-file-path= defaults to =gtd/linear.org=; one file holds the active view. +- *Requests and writers read the globals late, not at dispatch.* =pearl--graphql-request-async= reads the key/url when the request starts, and =pearl--update-org-from-issues= reads =pearl-org-file-path= at *callback/render* time — so a global changed mid-flight is read by the in-flight callback. This is the async-leak surface. +- The caches are module-level =defvar=s; =pearl-clear-cache= resets them all. Nothing scopes them to a workspace. +- =pearl--cache-viewer= caches "who am I" — inherently per-account. +- Rendered files carry =#+LINEAR-SOURCE= / run-at / filter / count / truncated, but *no account identity*. + +* Proposed design + +** The account model + +A new defcustom =pearl-accounts=: an alist of named accounts, each a plist of per-workspace settings. + +#+begin_src elisp +(setq pearl-accounts + '(("work" :api-key-source (:auth-source :host "api.linear.app" :user "work") + :org-file "~/org/work-linear.org" + :default-team-id "TEAM_WORK") + ("personal" :api-key-source (:env "LINEAR_PERSONAL_API_KEY") + :org-file "~/org/personal-linear.org" + :default-team-id nil))) +#+end_src + +=:api-key-source= is how the key is *found*, not the key itself — a concrete tagged plist: =(:auth-source :host H :user U)=, =(:env "VAR")=, or =(:literal "lin_...")= as an escape hatch. =:org-file= and the optional =:url= round it out. + +=pearl-default-account= is a defcustom (the user's durable preference for the startup account). =pearl-active-account= is *runtime state*, a plain =defvar= — never a persisted defcustom: the active account is which one you're on right now, and =pearl-switch-account= sets it with =setq=, never =customize-save-variable=. Persisting "active" would make the last switch stick across sessions in a surprising way and fight =pearl-default-account=. + +** Account-context layer (the core of the safety model) + +Rather than mutating globals and hoping downstream is oblivious, account state flows through an explicit context: + +- =pearl--resolve-account= (NAME) → a context plist =(:name :api-key :org-file :default-team-id :url)=, with =:api-key= resolved from =:api-key-source= and =:org-file= expanded. Errors clearly when the account or its key is missing. +- =pearl--current-account-context= returns the active account's context (resolving =pearl-default-account= on first need; see Startup). +- =pearl--with-account-context= (CONTEXT &rest body) dynamically binds the existing globals (=pearl-api-key=, =pearl-org-file-path=, =pearl-default-team-id=, =pearl-graphql-url=) to the context's values for BODY. Most downstream function *bodies* stay unchanged — they still read those globals — but now they read them under a controlled binding rather than a mutable global. (This replaces the draft's "downstream commands are unchanged" claim, which the review correctly rejected.) + +** Account context and async safety (HP1) + +Every API operation *snapshots* its account context at dispatch time. The snapshot is the =pearl--resolve-account= plist captured before the request fires. Render and mutate callbacks act against the *snapshot's* org file / key, never the current global. + +*v1 decision (final):* if the active account changed before a callback completes, the callback still finishes into its snapshot — writing the work result to the work file, with a status message naming the account ("Updated work: 24 issues"). It does *not* write to whatever file is now current, and it does *not* silently drop the result the user asked for. (The reviewer offered finish-into-snapshot or drop-stale; finish-into-snapshot is chosen because the fetch was an explicit user request whose data belongs to its own account's file regardless of a later switch, and round 2 endorsed it as the right default.) + +*Implementation boundary (where the snapshot is captured and applied).* So a single missed render path can't reintroduce the leak: + +- every public API/file command resolves =pearl--current-account-context= once at entry; +- async callbacks that render or mutate local state run *inside* =pearl--with-account-context= bound to that captured context — binding at dispatch alone isn't enough, because the late reads (e.g. =pearl--update-org-from-issues= reading =pearl-org-file-path=) happen in the callback body; +- shared render functions take an explicit context or target-file argument where practical, especially =pearl--update-org-from-issues=, rather than relying on the dynamic binding reaching them; +- raw =let= bindings of =pearl-api-key= / =pearl-org-file-path= scattered through command bodies are avoided — the macro and the explicit args are the only two paths, so the safety model stays auditable. + +** Account file ownership (HP2) + +Rendered files carry =#+LINEAR-ACCOUNT: <name>= in the header. Every command that hits the API or writes the file calls a shared guard first, =pearl--require-account-context= (or similar): + +- buffer account == active account → proceed; +- buffer account is configured but *not* active → *refuse* (v1, not auto-switch — auto-switch hides a state transition right before a remote mutation) with a message naming *both* accounts: "This file belongs to work; active account is personal. Run =M-x pearl-switch-account= first." +- *no account marker* while =pearl-accounts= is non-nil → non-mutating render/list commands may proceed (they don't risk wrong-account writes the way a mutation does), but *mutating* commands refuse: "No LINEAR-ACCOUNT in this file; refresh it under an account first." One refresh stamps ownership. (With =pearl-accounts= nil, an unmarked file is just legacy single-account behavior — no guard.) + +The mode-line indicator tells you the active account; this guard enforces it. Visibility alone is insufficient — the file/command context needs enforcement. + +** Active-account indicator (HP3, v1 requirement) + +A visible current-account indicator ships in v1, not as polish. The *v1 requirement* is a global mode-line lighter (=Pearl[work]=) — global, and easy to test after =pearl-switch-account=; a buffer-local header-line on account files is optional polish on top. The user must be able to tell the active account before running a mutating command. Pushing a work edit while thinking you're on personal is the failure mode this whole feature exists to prevent. + +** Switching accounts + +=pearl-switch-account= (interactive): =completing-read= over =pearl-accounts= names, then: + +1. Resolve the account's context (=pearl--resolve-account=). +2. Set =pearl-active-account= and install the context into the globals (the binding the rest of the package reads). +3. *Clear the account-scoped caches* via =pearl--clear-account-scoped-state= (below). +4. Update the indicator and optionally surface the account's org file. + +** Credentials (MP1) + +Keys resolve through =:api-key-source= at context-resolution time. =auth-source= (=~/.authinfo.gpg=) is the documented default; =:env= and =:literal= are supported. Hard rules: a resolved key is assigned with =setq= (or a dynamic binding) only — *never* =customize-save-variable= — so secrets don't land in =custom.el=; and debug/setup output must never log the key (a missing-key error names the account, e.g. "No API key for account work", without dumping the lookup). + +=pearl-load-api-key-from-env= stays legacy-only. With =pearl-accounts= nil it works as today (loads =LINEAR_API_KEY= into the global). With accounts configured it refuses ("Accounts are configured; put =:api-key-source (:env \"...\")= in =pearl-accounts=") so it can't bypass the resolver and overwrite the active account's key in memory. + +** Cache isolation (MP3) + +Clear-on-switch for v1 (keyed-per-account caches are vNext). =pearl--clear-account-scoped-state= is the single helper that clears every account-scoped cache — the five Linear lookups (=teams=, =states=, =team-collections=, =views=, =viewer=) plus any future account-derived state (e.g. the workflow-state-derived TODO keyword caches, if that feature lands). =pearl-switch-account= calls this helper rather than re-implementing cache semantics, so a newly added account-scoped cache is registered in one place. A test asserts every cache the helper names is nil after a switch. + +** Saved queries (MP2) + +v1 keeps =pearl-saved-queries= a single shared list, but a query entry may carry an optional =:account= field alongside =:filter= / =:sort= / =:order=: + +#+begin_src elisp +("My work bugs" :account "work" + :filter (:team "ENG" :label "bug" :assignee :me)) +#+end_src + +=pearl-run-saved-query= checks =:account= *before* any name-to-id filter compilation or network call: if =:account= names a different account than the active one, it refuses immediately ("Saved query 'My work bugs' belongs to work; switch accounts first"). The filter's team/state/label names resolve against the active workspace, so "Backlog" can silently mean different things or nothing across accounts — the guard catches that before a wrong-account fetch. The README warns that saved queries without =:account= are shared and resolved against whatever account is active. (Full account-scoping is vNext.) + +** Startup / default resolution (HP4) + +Exactly one rule: + +- =pearl-accounts= nil → the legacy globals are used unchanged (single-account, today's behavior). +- =pearl-accounts= non-nil and =pearl-active-account= nil → the first command that needs account context resolves =pearl-default-account=; if no default is configured, it errors with a message telling the user to set one or run =pearl-switch-account=. +- =pearl-switch-account= and =pearl--current-account-context= are the only paths that install/return account settings. + +** What's account-scoped + +Everything that hits the API or writes the file: list/view/saved-query fetches, the sync/save commands, field setters, comment commands, =new-issue=, the caches, and the viewer identity (comment-edit permission is per-account). All of it flows through the context layer: snapshot at dispatch, bind for the body, guard the buffer. + +* Proposed v1 decisions (this feature) + +1. =pearl-accounts= defcustom (name → plist: =:api-key-source=, =:org-file=, =:default-team-id=, optional =:url=); =pearl-default-account= defcustom; =pearl-active-account= a runtime =defvar=, set with =setq=, never persisted. +2. An account-context layer (=pearl--resolve-account= → plist, =pearl--with-account-context=, =pearl--current-account-context=); downstream bodies stay mostly unchanged behind it, with the implementation-boundary rule (entry resolves context; callbacks run inside the context macro; shared render fns take an explicit target where late reads are dangerous). +3. Dispatch-time context snapshots; a callback finishes into its snapshot's file/context even if the account switched mid-flight, messaging the account. +4. =#+LINEAR-ACCOUNT:= header on rendered files + a shared buffer guard: refuse a command from another account's file (naming both accounts); from an unmarked file under accounts, render/list may proceed but mutations refuse until a refresh stamps ownership. +5. A mode-line lighter is the v1 active-account indicator (header-line optional). +6. Credentials via a tagged =:api-key-source= plist (auth-source default, env + literal supported); resolved keys never persisted via Customize, never logged; =pearl-load-api-key-from-env= is legacy-only and refuses once accounts are configured. +7. Clear-on-switch via =pearl--clear-account-scoped-state= (keyed caches vNext). +8. Saved queries shared with an optional =:account= guard checked before filter compilation + a README warning. +9. Exact startup rule; passive migration. + +Decisions 3, 4, and 8 (and the unmarked-mutation refusal in 4) were the safety calls; they are now *final* — adopted as the safer defaults, endorsed across the reviews. + +* Resolved safety decisions + +The five safety calls are final (adopted as the safer defaults, round-3-endorsed): + +1. *Async-stale callback*: finish into the snapshot's file with an account-named message (not drop). +2. *Wrong-account buffer*: refuse with a message naming both accounts (not auto-switch). +3. *Unmarked file under accounts*: render/list proceed; mutations refuse until a refresh stamps =#+LINEAR-ACCOUNT=. +4. *Saved queries*: shared list + optional =:account= guard checked before filter compilation (not fully account-scoped in v1). +5. *Endpoint* =:url=: optional-but-supported through the same resolver. + +The vNext alternatives (drop-stale, auto-switch, fully account-scoped queries) stay in vNext if a real need appears. + +* Files touched + +- =pearl.el=: the =pearl-accounts= / =pearl-active-account= / =pearl-default-account= defcustoms; the context layer (=pearl--resolve-account=, =pearl--current-account-context=, =pearl--with-account-context=); the =:api-key-source= resolver; =pearl-switch-account= + =pearl--clear-account-scoped-state=; =#+LINEAR-ACCOUNT= emission in =pearl--build-org-content= and the =pearl--require-account-context= buffer guard wired into the API/file commands; dispatch-time context capture in the async entry points + render/mutate callbacks; the mode-line/header indicator; passive legacy fallback. +- =docs/=: this spec. +- =README.org=: a multi-account section (auth-source entries, example =pearl-accounts=, the saved-query warning, the indicator). + +* Test plan + +- =pearl--resolve-account=: a named account's plist resolves the expected key / org-file (expanded) / default-team / optional url; an unknown name errors. +- =:api-key-source= resolver: stubbed auth-source yields the key; =:env= reads the variable; a missing entry errors naming the account ("No API key for account work") without leaking lookup internals; the resolved key is never persisted through Customize. +- *Async safety (HP1):* dispatch a list fetch under "work", switch to "personal" before invoking the captured callback, assert the result writes the *work* org file (or is dropped, per the chosen rule). +- *Buffer guard (HP2):* render a work file with =#+LINEAR-ACCOUNT: work=, set active to personal, assert mutating commands (=refresh-current-view=, =save-issue=, a field setter, a comment command, =delete-current-issue=) refuse *before* any API call. +- *Startup (HP4):* a command run with accounts configured but no active account resolves =pearl-default-account=, or errors when none is set. +- =pearl--clear-account-scoped-state=: every cache it names is nil after =switch-account=; the viewer cache invalidates so comment-edit permission re-resolves. +- Back-compat: =pearl-accounts= nil + legacy =pearl-api-key= → unchanged single-account behavior. +- =pearl-active-account= is runtime state — set by =switch-account= with =setq=, not persisted through Customize. +- =pearl-load-api-key-from-env= refuses when =pearl-accounts= is non-nil; works as legacy when nil. +- Unmarked buffer + accounts enabled: a render/list command proceeds, a mutating command refuses ("No LINEAR-ACCOUNT…"). +- Saved query with =:account= refuses under a different active account *before* any filter compilation or resolver/network call. +- Indicator: the mode-line lighter updates after =pearl-switch-account=. +- Killed-buffer / lost-marker safety on a render callback that completes after the buffer is gone. + +* Migration (MP4) + +Passive. With =pearl-accounts= unset, the package behaves exactly as today off the legacy globals. When accounts are configured, the README shows how to create a default account. The credential move to auth-source is opt-in (the =:literal= / env forms keep working). Interactive first-run seeding is vNext, not v1. + +* Review Dispositions + +*Round 1 (Codex, 2026-05-25).* Rubric =Not ready=. The four blocking findings (HP1 async cross-account writes, HP2 wrong-account buffer commands, HP3 indicator-is-v1, HP4 startup resolution) are accepted and drove the new account-context layer, the dispatch-time snapshot rule, the =#+LINEAR-ACCOUNT= buffer guard, the required indicator, and the exact startup rule. MP1 (concrete credential plist, setq-only, no logging) and MP4 (passive migration) accepted as written. The architecture, UX, robustness, and test-strategy observations are folded into the body and test plan. Two were modified: + +- *MP2 (saved queries) — modified to a choice.* The reviewer offered account-scoped lists, an optional =:account= guard, or document-and-warn. Chose the optional =:account= guard plus a shared default and a README warning: it keeps the v1 saved-query store unchanged (one list) while giving a refusal path for queries that must not cross accounts, where full account-scoping would add a persistence/UX layer not yet justified for two workspaces. +- *MP3 (cache clearing) — modified the test framing.* Accepted the helper (=pearl--clear-account-scoped-state= as the single source of truth, listing every account-scoped cache including future derived state). Softened the reviewer's "a test must fail when a new account-scoped cache is added without invalidation" — you can't test for an unwritten future cache directly. Instead the helper centralizes the list so a new cache is registered in one place, and a test asserts each cache the helper names is nil after a switch. + +Also, the reviewer's three open questions and the HP1 finish-vs-drop choice are baked as v1 decisions (finish-into-snapshot, refuse-on-mismatch, =:account= guard) and adopted as final — see Resolved safety decisions. Everything else accepted as written. + +*Round 2 (Codex, 2026-05-25).* Rubric moved to =Ready with caveats=. All findings accepted as written, no modifications or rejects: HP1 (the implementation-boundary rule — entry resolves context, callbacks run inside the context macro, shared render fns take an explicit target), HP2 (the precise unmarked-buffer rule — render/list proceed, mutations refuse until a refresh stamps ownership), HP3 (=pearl-active-account= is a runtime =defvar=, not a persisted defcustom — a genuine correction to the round-1 draft), MP1 (=pearl-load-api-key-from-env= legacy-only, refuses under accounts), MP2 (=:account= lives in the saved-query entry and is checked before filter compilation, with an example), MP3 (mode-line lighter is the v1 indicator, header-line optional), plus the UX refusal-message-names-both-accounts and the added tests. The remaining caveats are the safety calls, now endorsed by the reviewer. + +*Round 3 (Codex, 2026-05-25).* Rubric =Ready with caveats=; no new findings — MP1-MP5 were each "confirm decision X" on the five safety calls, all endorsed as the safer defaults. Resolved by adopting all five as final v1 decisions (Craig granted the latitude to adopt the review's recommendations directly). Rubric -> =Ready=. + +* vNext / out of scope + +- Per-account keyed caches (warm switch-back), if clear-on-switch proves annoying. +- Auto-switch (instead of refuse) from a visited account file. +- Showing both accounts at once (split buffers / frames) — the model is one active account at a time. +- Fully account-scoped =pearl-saved-queries=. +- Interactive migration/seeding from the legacy single-account globals. +- Auto-detecting the account from the active file on visit. diff --git a/docs/specs/multi-state-filter-spec.org b/docs/specs/multi-state-filter-spec.org new file mode 100644 index 0000000..26475be --- /dev/null +++ b/docs/specs/multi-state-filter-spec.org @@ -0,0 +1,148 @@ +#+TITLE: Spec: selecting multiple statuses in a filter +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-01 + +* Status + +*Ready.* Codex review incorporated (2026-06-01, =Ready with caveats=); the one medium finding (reconcile the views-spec/README representability boundary when =:state= turns plural-capable) is folded into phase 3. See *Review dispositions*. Triggered by the 2026-06-01 conversation after the local/Linear views work shipped: the interactive filter builder lets you pick exactly one workflow state, so a filter like "Todo or In Review" can't be built. Linear's API supports it (=state.name.in=), and Pearl's filter model is one step away. This closes the named-states half of the views spec's vNext "plural authoring keys" item ([[file:local-and-linear-views-spec.org][local-and-linear-views-spec.org]], Out of scope). + +* Problem + +A workflow state ("status" in Linear's UI) is the field people most want to filter on by *set* -- "show me everything in Todo, In Progress, or In Review." Pearl can't express that: + +- =pearl--read-filter-interactively='s State prompt is a single =completing-read=, so it picks one state name. +- The =:state= authoring key compiles to =state.name.eq= -- a single name. +- The only ways to match several states today are =:state-type= (by type bucket: backlog / unstarted / started / completed / canceled, not by individual name) and =:open t= (the broad not-closed predicate). Neither lets you hand-pick, say, "Todo and In Review but not In Progress." + +So the most common multi-value filter a user wants is the one Pearl's builder can't make. + +* Non-goals + +- *Not* OR across different dimensions. Filters stay AND-only between dimensions (state AND label AND assignee); this is OR *within* the state dimension only, which Linear expresses as =state.name.in=. +- *Not* multi-value on the other singular keys (=:project=, =:assignee-id=, =:team=, =:cycle=, =:label-id=). Those remain singular; their plural keys are a separate vNext item. +- *Not* negation ("not In Progress"). Still out of scope. +- *Not* exposing =:state-type= in the builder. It already accepts a list in the DSL; surfacing the type buckets in the UI is a separate, smaller question. + +* Proposed design + +*Make =:state= polymorphic -- a name or a list of names -- exactly as =:state-type= already is.* This adds the capability with no new authoring key and a surface symmetric with the sibling key. + +** Authoring model + +- =(:state "In Progress")= -- a string -- compiles to =state.name.eq= (unchanged). +- =(:state ("Todo" "In Review"))= -- a list -- compiles to =state.name.in= [names]. + +=:state= and =:state-type= stay mutually exclusive in practice (you filter by name *or* by type), and the existing precedence holds: =:state= over =:state-type= over =:open=. No new key, no precedence change. + +** Compiler (=pearl--compile-state-filter=) + +The =:state= branch gains the same one-or-list shape =:state-type= has: + +#+begin_src emacs-lisp + (state (list (cons "name" + (if (listp state) (pearl--in state) (pearl--eq state))))) +#+end_src + +** Interactive builder (=pearl--read-filter-interactively=) + +The State prompt becomes a =completing-read-multiple= over the team's fetched state names (mirroring how the Labels prompt already works), with the =[ Any. ]= sentinel filtered out of the result. The result maps to the authoring value: + +- none / only the sentinel -> no =:state= constraint. +- exactly one -> a string, =(:state "X")= (keeps the common single-state filter as a scalar). +- two or more -> a list, =(:state ("X" "Y"))=. + +Single-to-string keeps the simple case identical to today and keeps round-trips clean. + +** Validation (=pearl--validate-issue-filter=) + +Today =:state= is validated in the singular-key group that only rejects an empty string. With =:state= now accepting a list, it moves to the same validation =:labels= already gets: a string, or a list of non-empty strings; an empty name anywhere (=(:state ("" "Todo"))=) signals a `user-error' at the command boundary, so a bad filter never reaches the compiler or the network. + +** Reverse-compile (copy-down, =pearl--rc-match-state=) + +Copy-down currently refuses a multi-value =state.name.in= (the singular-key rule). With =:state= now plural-capable, =state.name= reverse-compiles like =:state-type=: + +- =state.name.eq= -> =(:state "X")=. +- =state.name.in= with one element -> =(:state "X")= (normalized to a scalar). +- =state.name.in= with several -> =(:state ("X" "Y"))=. + +So a Linear view filtering on a set of named states copies down cleanly instead of refusing. + +* Current state + +The four filter-side readers of the =:state= authoring key: + +- =pearl--compile-state-filter= -- singular =state.name.eq=. +- =pearl--read-filter-interactively= -- single-select State prompt. +- =pearl--rc-match-state= (reverse-compile) -- treats =state.name.in= [many] as an unrepresentable multi-value refusal. +- =pearl--validate-issue-filter= -- validates =:state= as a single non-empty string. + +All four change. =:state-type= is already one-or-list (the precedent this spec follows), and =:open t= is the broad not-closed predicate. + +*Unaffected:* the issue's *own* workflow state is a different use of the symbol =:state= -- =pearl--normalize-state=, the renderer and sorter (=(plist-get issue :state)=), and the save model's edited-state push all read an issue's state, not a filter key, and need no change. + +* Migration + +None. =(:state "X")= entries keep working (a string still compiles to =state.name.eq=). The change is purely additive: a list value becomes legal. The =:state= docstring updates from "(name)" to "(a name or a list of names)". + +* Acceptance criteria + +- The builder's State prompt is multi-select; picking two states yields =(:state ("A" "B"))=, picking one yields =(:state "A")=, picking none yields no =:state= key. +- =(:state ("A" "B"))= compiles to =state.name.in= ["A" "B"]; =(:state "A")= still compiles to =state.name.eq=. +- A list-valued =:state= keeps its precedence: it still beats =:state-type= and =:open= in =pearl--compile-state-filter=. +- A local view with a multi-state =:state= runs and returns issues in any of the named states. +- Copy-down reverse-compiles =state.name.in= [many] to =(:state (list))= and [one] to =(:state "X")=; it no longer refuses a multi-named-state Linear view. +- Round-trip holds: =(:state ("A" "B"))= -> build -> reverse -> =(:state ("A" "B"))=. +- Validation: =(:state ("A" "B"))= is accepted; =(:state ("" "B"))= (an empty name) signals a `user-error'; =(:state "A")= still validates. +- The =#+LINEAR-SOURCE= header round-trips a multi-state filter: a buffer rendered from =(:state ("A" "B"))= writes and re-reads that list intact, so refresh re-runs the same filter. +- Existing single-state behavior and tests are unchanged. +- Full ERT suite green, =make lint= and byte-compile clean. + +* Implementation phases (commits) + +1. *Compiler + reverse-compile + validator* -- =:state= accepts a list (=state.name.in=); =pearl--rc-match-state= inverts =state.name.in= to a scalar or list and drops the multi-name refusal; =pearl--validate-issue-filter= validates =:state= as a string or a list of non-empty strings. Round-trip, compile, and validation tests. (=feat:=) +2. *Builder multi-select* -- the State prompt becomes =completing-read-multiple=; one-to-string, many-to-list mapping. Builder tests. (=feat:=) +3. *Docs + boundary reconciliation* -- README's filter section and the =:state= docstring note multi-state selection; tick the named-states item off the views spec's vNext plural-keys list. *And reconcile the representability boundary the views work documented as singular-only:* update =local-and-linear-views-spec.org='s reverse-compile dimension table, the singular-vs-plural rule, the refusal list, and the acceptance criteria, plus the README copy-down paragraph, so =state.name.in= with several names is a plural-capable exception alongside =labels.some.name.in= (=:labels=) and =state.type.in= (=:state-type=). This must land with phase 1 (the code that drops the refusal), not before -- until the reverse-compile changes, those statements are still true. (=docs:=) + +* Out of scope (vNext) + +- Plural keys for the other singular dimensions (=:projects=, =:assignees=, =:cycles=, =:team-keys=, =:label-ids=) -- the rest of the views spec's plural-keys item. +- Exposing =:state-type= (the type buckets) in the builder. +- Negation and cross-dimension OR. + +* Implementation tasks (drop-in for todo.org) + +#+begin_src org +,** TODO [#B] Multi-state filter — compiler, reverse-compile, validator :feature: +=:state= accepts a list (=state.name.in=); =pearl--rc-match-state= inverts =state.name.in= to a scalar (one) or list (many) and drops the multi-name refusal; =pearl--validate-issue-filter= validates =:state= as a string or a list of non-empty strings; a list-valued =:state= keeps precedence over =:state-type= / =:open=. Spec: [[file:docs/specs/multi-state-filter-spec.org]] (phase 1). + +,** TODO [#B] Multi-state filter — builder multi-select :feature: +The State prompt becomes =completing-read-multiple= with the =[ Any. ]= sentinel filtered out and one-to-string, many-to-list mapping. Spec: [[file:docs/specs/multi-state-filter-spec.org]] (phase 2). + +,** TODO [#B] Multi-state filter — docs + boundary reconciliation :docs: +README filter section + =:state= docstring document multi-state selection; the local/Linear views spec's reverse-compile dimension table, singular-vs-plural rule, refusal list, acceptance, and README copy-down paragraph add =state.name.in=[NAMES...] as a plural-capable exception; the named-states vNext item is ticked off. Lands with phase 1. Spec: [[file:docs/specs/multi-state-filter-spec.org]] (phase 3). + +,** TODO [#B] Multi-state filter — test surface :test: +Unit: =(:state ("A" "B"))= → =state.name.in=, =(:state "A")= → =eq=, list keeps precedence over =:open=, validator accepts list / rejects empty-name; reverse-compile round-trip + direct =state.name.in= JSON fixtures (one→scalar, many→list); builder/assemble-filter one→string, many→list, sentinel→no key; source-header round-trip of =:filter (:state ("A" "B"))=. Integration: a local view with =(:state ("A" "B"))= runs the normal path. E2e/manual: build a two-state filter, run it, copy down a multi-named-state Linear view, confirm refresh preserves the =#+LINEAR-SOURCE= filter. Spec: [[file:docs/specs/multi-state-filter-spec.org]] (Acceptance criteria). +#+end_src + +* Review dispositions + +The Codex review (=Ready with caveats=) had no high-priority findings and one medium, accepted: + +- *MP1 (reconcile the representability boundary) — accepted.* The views work documented multi-value =in= on singular keys as refusing, with only =:labels= and =:state-type= whitelisted. Making =:state= plural-capable changes that, so phase 3 now explicitly updates the views spec's dimension table, singular-vs-plural rule, refusal list, and acceptance, plus the README copy-down paragraph -- and the spec notes this must land with phase 1 (the code that drops the refusal), since until then the old boundary text is still accurate. + +The test-strategy recommendations were folded into the acceptance criteria and the test-surface task (precedence-with-list, direct =state.name.in= JSON fixtures, the source-header round-trip). Everything else was accepted as written. + +* Review and iteration history + +** 2026-06-01 Mon @ 23:10:17 -0500 — Codex — reviewer + +- *What changed or was recommended:* Reviewed the spec against the current filter compiler, reverse-compiler, validator, interactive builder, source-header serialization, tests, README, related views spec, and task tracking. Verdict: =Ready with caveats=. No high-priority blockers. One medium-priority caveat: phase 3 must update the local/Linear views spec and README representability boundary so =state.name.in= with multiple names is no longer documented as an unrepresentable singular-key filter. +- *Why:* The implementation surface is small and well specified, but the new plural-capable =:state= rule intentionally changes the boundary just established by the views work. Keeping the companion docs/spec in sync prevents future copy-down behavior from contradicting its documented refusal rules. +- *Artifacts:* =multi-state-filter-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 23:13:11 -0500 — Claude Code (pearl) — responder + +- *What changed:* Accepted the one medium finding (MP1) and folded it into phase 3: the docs phase now reconciles the local/Linear views spec's representability boundary and the README copy-down paragraph so =state.name.in= [many] is a plural-capable exception, with the note that it lands with phase 1 (not before, since the refusal is still accurate until the code changes). Folded the test-strategy recommendations into acceptance and the test-surface task (list keeps precedence over =:open=, direct =state.name.in= JSON fixtures, source-header round-trip), added the drop-in task block, and raised status to Ready. +- *Why:* The finding was a real coherence risk -- the just-shipped views work documents singular keys as refusing multi-value =in=, and this spec intentionally moves =:state= out of that set. Naming the boundary reconciliation in phase 3 keeps the docs from contradicting the code. +- *Artifacts:* Review file deleted on completion. diff --git a/docs/specs/refine-source-spec.org b/docs/specs/refine-source-spec.org new file mode 100644 index 0000000..998a37d --- /dev/null +++ b/docs/specs/refine-source-spec.org @@ -0,0 +1,105 @@ +#+TITLE: Spec: refine the current source + +* Status + +Draft (2026-06-23). Pending Craig's sign-off on the six decisions below — the mutate-vs-layer call (Decision 1) is load-bearing for refresh semantics and should be settled before any code. + +* Problem + +When the buffer is showing a project, a view, a label, or any rendered source, there's no command to say "now show me just the open ones assigned to Vrezh from this set." The only path is to abandon the current source and rebuild the whole filter from scratch through =pearl-list-issues-filtered= (=C-; L f f=). The user's mental model is "I'm on this page, narrow it." Pearl's model is "start over with a new filter." + +Org has =org-match-sparse-tree=, which can client-side hide non-matching headings, but it's an org trick the user has to know, it isn't pearl-aware, and the next refresh blows it away. + +Triggered by: 2026-05-27 Craig during the issue-sources Test 2 walk-through ("when I'm on a project page and I want to scope the filter further, is there a way to do that?"). + +* Current state (the machinery this builds on) + +- =pearl--read-active-source= reads =#+LINEAR-SOURCE= back into a source plist; =pearl--write-linear-source-header= writes one back. =pearl--source-with-grouping= is the precedent for "modify a source in place and rewrite the header." +- The filter compiler =pearl--build-issue-filter= merges single keys into one IssueFilter: =:project=, =:open=, =:state= / =:state-type=, =:label-id=, =:assignee-id= / =:assignee :me=, =:priority=, =:cycle=, =:team=. Adding one constraint is one more key on the plist. +- =pearl--query-view-async= already takes an optional =filter= arg that the API AND-combines with the view's Linear-side =filterData= (the same mechanism the show-completed-issues work used). So a *view* can be refined server-side without touching its Linear definition. +- =pearl--read-filter-interactively= is the full builder. It prompts every dimension; refining wants exactly one. + +* The six decisions + +** Decision 1 — mutate the source (sticky refine), via a refinement stack [LOAD-BEARING] + +*Recommend: mutate.* The source grows an ordered =:refinements= list, each entry a single-dimension constraint plist (=(:assignee-id "...")=, =(:state-type "started")=, etc.). The effective fetch is the base source plus every refinement AND-combined. Refresh (=C-; L g=) re-applies base + refinements, so a narrow is sticky — exactly the "I'm on this page" mental model. + +The layer alternative (refinement is a session-local overlay, refresh reverts to the base) was considered and rejected: it makes refresh surprising (the narrow vanishes) and can't survive a buffer reopen, which is most of the value. + +The refinement *stack* (not a single slot) settles Decision 3 in the same stroke. + +** Decision 2 — server-side re-fetch, not client-side narrow + +*Recommend: server-side.* Pearl's whole model is "the buffer mirrors Linear," and a client-side hide can't see issues added since the original fetch. Compile the refinements to an IssueFilter and re-fetch. The machinery exists for both source types (Decision 4). + +Client-side (=org-match-sparse-tree=) is rejected for the same reasons it's inadequate today: not pearl-aware, blown away on refresh. + +** Decision 3 — stackable, with an un-refine + +*Recommend: stackable.* Because the source carries a =:refinements= list, the user can narrow repeatedly (project → assignee → priority). =pearl-unrefine-current-source= pops the last refinement and re-fetches; running the base source again (or a dedicated reset) clears all. Each refine and un-refine rewrites the header and re-renders. + +** Decision 4 — view sources refine server-side too (no new probing) + +*Recommend: server-side for views via the existing =filter= arg.* A =:type view= source keeps its base filter Linear-side; the refinements compile to an IssueFilter passed as =pearl--query-view-async='s optional =filter=, which the API AND-combines with the view's =filterData=. This is the proven show-completed mechanism, so no live probe is needed. The refinements must be expressible as IssueFilter, which our compiler guarantees. + +So both =:type filter= and =:type view= refine through one code path: compile the =:refinements= to a filter, then fetch (filter source merges into its own filter; view source passes it as the extra =filter= arg). + +** Decision 5 — keep the header readable + +*Recommend: structured data in the header, a short summary in the title.* =#+LINEAR-SOURCE= carries the base source plus the =:refinements= list verbatim (it drives refresh; it's allowed to be long, and =pearl--linear-source-string= already prints in full). The human-facing =#+title= gets a short appended summary, e.g. =Linear — Orchestration Dashboard + @vrezh · open=, so the H1 stays scannable. The refine command composes that summary from the refinement labels. + +** Decision 6 — factor a one-dimension reader out of the builder + +*Recommend: extract =pearl--read-one-dimension=.* It prompts "Refine by: " over the refinable dimensions (state / label / assignee / priority, plus cycle / project as natural extensions), then prompts the value for the chosen dimension reusing the existing per-dimension =completing-read= helpers, and returns a one-key plist to push onto =:refinements=. =pearl--read-filter-interactively= can then be expressed as a loop over the same reader, or left as-is and the reader shared — either way no duplicated dimension logic. + +* Proposed commands + +- =pearl-refine-current-source= (=C-; L f r=) — read =#+LINEAR-SOURCE=, prompt one dimension + value via =pearl--read-one-dimension=, push it onto =:refinements=, rewrite the header, re-fetch + re-render. +- =pearl-unrefine-current-source= (=C-; L f R=, tentative) — pop the last refinement, rewrite, re-fetch. A no-op with a message when there are none. + +Both refuse with a clear message when the buffer has no =#+LINEAR-SOURCE= (not a pearl buffer) or when the source type can't be refined (a =:type issue= single-issue buffer — refining one issue is meaningless). + +* Source model + +A refined source: + +#+begin_example + (:type filter :name "Orchestration Dashboard" + :filter (:project "..." :open t) + :refinements ((:assignee-id "vrezh-uuid") (:state-type "started"))) +#+end_example + +Effective filter = base =:filter= with each refinement merged in (filter source), or the AND-combined refinements passed as the view =filter= arg (view source). =:refinements= absent or nil = today's behavior, so every existing buffer round-trips unchanged. + +* Acceptance criteria + +- On a filter source, =pearl-refine-current-source= narrows by one added dimension, the header records the refinement, and refresh re-runs the narrowed filter (sticky). +- On a view source, the same works via the AND-combined =filter= arg without altering the Linear view. +- Refinements stack; =pearl-unrefine-current-source= peels the last; clearing all returns the base result. +- The =#+title= shows a short refinement summary; =#+LINEAR-SOURCE= carries the structured refinements and round-trips. +- A non-pearl buffer and a =:type issue= buffer both refuse with a message and no fetch. +- A refinement on a dimension already present replaces rather than duplicates (e.g. refine assignee twice = the last assignee wins), or stacks as an explicit decision — see Open questions. + +* Implementation phases (commits) + +1. =pearl--read-one-dimension= extracted + unit tests (pure-ish: mock the completing-reads). +2. The refinement model: compile =:refinements= into the effective filter for both source types; round-trip tests. +3. =pearl-refine-current-source= + =pearl-unrefine-current-source=, header/title rewrite, dispatch for filter vs view; the keymap + transient bindings. Tests + a live manual-verify entry. + +* Out of scope (vNext) + +- Saving a refined source as a named local view (the existing "save current source" task already covers capturing a buffer's filter; a refined source is just a filter and rides that). +- Multi-value refinement on one dimension in a single step (the multi-state filter already makes =:state= a list; refining adds one value at a time for now). +- A transient-style one-screen refine UI (the ad-hoc-builder-UX task owns that surface; this spec stays prompt-based). + +* Open questions for Craig + +1. Decision 1 (mutate vs layer) — confirm sticky-refine-via-mutation is the model. +2. Replace-vs-stack on a repeated dimension (Acceptance, last bullet): if you refine assignee, then refine assignee again, does the second replace the first (recommend: replace, since two assignee constraints rarely both apply) or stack as an AND (which for assignee would usually yield nothing)? +3. The un-refine key =C-; L f R= — acceptable, or prefer a different binding / a reset-all instead of a pop? + +* Review and iteration history + +** 2026-06-23 Tue — Claude Code (pearl) — author +Drafted from the todo task's six design questions, grounded in the filter compiler and the view =filter= arg. Recommends mutate + server-side + stackable + one-dimension reader. Pending Craig's calls on the three open questions. diff --git a/docs/specs/saved-query-sync-spec.org b/docs/specs/saved-query-sync-spec.org new file mode 100644 index 0000000..a17239e --- /dev/null +++ b/docs/specs/saved-query-sync-spec.org @@ -0,0 +1,309 @@ +#+TITLE: Spec: sync local saved queries to Linear views +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-05-28 + +* Status + +*Done.* Shipped 2026-05-28 across commits 60a026b (Phase 2: pearl-sync-saved-query-to-linear + transient entry), c8b9ad1 (Phase 3: extend pearl-delete-saved-query with delete-on-Linear prompt), fd94237 (Phase 4: pearl-pick-source distinguishes [saved] / [saved → scope] / [view] + :linear-view-url plumbing), and 0975e7d (Phase 5: pearl-publish-current-source convenience wrapper). README sync-up section and this status flip in the Phase 6 docs commit. Sprint review with Craig completed 2026-05-28; all six open questions dispositioned (Q1, Q2, Q3, Q4, Q5, Q6, plus probe-surfaced Q7 absorbed by the Q2 collapse). Triggered by the conversation on 2026-05-28 after =pearl-delete-saved-query= shipped ("we'll need functionality that allows us to delete these, and also syncs non-local versions back to Linear"). Sibling spec to =docs/issue-sources-spec.org=, which established favorites + the unified picker but kept local saved queries as a local-only Customize variable. + +Live-API probe completed 2026-05-28 (see § Review dispositions). Mutations confirmed: =customViewCreate=, =customViewUpdate=, =customViewDelete= (no archive). =CustomViewCreateInput.filterData= is type =IssueFilter= -- the same type pearl's existing compiler produces, so the mapping is a pass-through. Implementation phase 1 trivializes accordingly. + +* Problem + +Pearl's local =pearl-saved-queries= are useful but isolated. A query authored in pearl lives in one user's Emacs configuration. The team can't see it, the Linear web UI can't run it, and switching machines means losing the work. Linear's own answer to "named, reusable query" is *Custom Views*, which are first-class: shareable, scoped to a team or to the viewer, archivable, and visible alongside the user's Linear sidebar favorites. + +Pearl already reads custom views (=pearl-run-view=, favorites dispatch). What it doesn't do is write them. So a saved query the user grew through pearl's ad-hoc builder is stuck local, even when it would be obviously useful as a team-visible view. Closing that gap is "sync the local one up so it becomes a view," not a wholesale move to Linear-side storage. + +The save-vs-run asymmetry today: a saved query is created in pearl, persists locally, runs from pearl. A view is created in Linear's web UI, persists on Linear, runs from anywhere including pearl. Sync-up bridges those two paths so a saved query authored in pearl can graduate to a view without the user re-creating it in the web UI. + +* Non-goals + +- *Two-way sync.* Read-down already works (pearl reads views). Write-down (re-pull view changes into a local saved query) is not v1. A synced query treats Linear as the source of truth from sync-up onward; if Craig edits the view in Linear, pearl sees the new shape on the next refresh. +- *Local saved-query elimination.* Local saved queries stay supported as the lightweight, no-API-cost path. Sync-up is opt-in per query, not automatic. +- *Cross-account sharing.* Multi-account support is a separate spec ([[file:multi-account-spec.org][multi-account-spec.org]]). v1 sync targets the currently-active account. +- *Editing the *view's* filter through pearl-run-view's rendered buffer.* The buffer is a view of the view; editing the filter happens by editing the local saved query and re-syncing. +- *Inverse migration (delete a view → re-create as local-only saved query).* Out of scope; if the user wants local, they author local. + +* Current state (what exists, what's missing) + +** Exists + +- =pearl-saved-queries= defcustom (alist of NAME . (=:filter= PLIST [=:sort= S] [=:order= O])). +- =pearl-run-saved-query= reads the entry and renders. +- =pearl-delete-saved-query= ([[file:../pearl.el::3467][pearl.el:3467]]) removes it locally. +- =pearl-list-issues-filtered= builds and optionally saves a query (=pearl--save-query=). +- =pearl-run-view= ([[file:../pearl.el::4696][pearl.el:4696]]) renders a Linear custom view by id. +- =pearl--query-view-async= + =pearl-view-issues-query= ([[file:../pearl.el::1077][pearl.el:1077]]) issue the =customView(id:)= read. +- =pearl-pick-source= lists favorites + saved queries side by side; favorites with kind =view= route to =pearl-run-view=. +- Filter compiler =pearl--build-issue-filter= produces the Linear =IssueFilter= JSON pearl uses against =issues(filter:)=. + +** Missing + +- Any mutation against Linear's =customViewCreate=, =customViewUpdate=, =customViewArchive= (or whatever Linear's current mutation names are; see Open questions §1). +- A mapping from pearl's authoring filter plist to the JSON shape Linear's =customView.filterData= accepts. +- A backlink: =pearl-saved-queries= entries carry =:filter= today and nothing else identifying a remote counterpart. +- A user-facing command to push a saved query up, and the surrounding UX (team selection, name conflict handling, archive-on-delete prompt). +- Picker disambiguation for synced vs local-only. + +* Proposed design + +** Schema extension on pearl-saved-queries + +Each saved-query entry becomes: + +#+begin_src elisp +("name" :filter PLIST + :sort S ; optional, existing + :order O ; optional, existing + :linear-view-id "UUID" ; NEW: set after a successful sync + :linear-view-team-id "UUID" ; NEW: optional, the team scope (nil = no team scope) + :linear-view-shared t-or-nil ; NEW: shared=t means team/everyone can see; nil means viewer-only + :linear-view-synced-at "2026-05-28T01:30:00Z" ; NEW: provenance + ) +#+end_src + +The =:linear-view-id= key is the synced-vs-local discriminator. Absent = local-only. Present = backed by a Linear view; refresh/runs go through =customView(id:)= rather than =issues(filter:)=. + +The =:linear-view-team-id= records the team scope at sync time, so a later re-sync can re-push to the right team. A view with no team scope leaves it nil. + +The =:linear-view-shared= records the visibility at sync time. =nil= or absent = personal (only the viewer sees it); =t= = visible to the team or workspace per the team scope. The Linear API exposes this as a separate =shared: Boolean= input alongside the scope ids. + +The =:linear-view-synced-at= is provenance only; not used to gate behavior in v1, but available for "you synced this 3 weeks ago, the local filter has changed since" diagnostics if we want them later. + +*Project and initiative scopes (=projectId=, =initiativeId= on the Linear input) are not surfaced in v1.* The API supports them, but the typical sync target is a team view. Adding the prompts is a vNext layer. + +** User-facing command surface + +Three commands, plus updates to two existing commands. + +*** New: =pearl-sync-saved-query-to-linear= + +Promotes a local saved query to a Linear custom view, or updates the linked view if already synced. + +Interactive flow: +1. Prompt for which saved query (completing-read over =pearl-saved-queries= names, =[ Cancel. ]= sentinel). +2. If the entry has no =:linear-view-id= (first-time sync): one enriched scope-and-visibility prompt. Each candidate spells out the complete end-state so the user picks a destination, not two orthogonal dimensions to mentally assemble: + #+begin_example + Where does this view live? + [ Team: Engineering, visible to the team ] <- default when :team Engineering in filter + [ Personal, only I see it ] + [ Team: Engineering, only I see it ] + [ Team: Marketing, visible to the team ] + [ Team: Marketing, only I see it ] + ... + [ Cancel. ] + #+end_example + The candidate list is built from the user's teams. For each team, two rows (=visible to the team= and =only I see it=). Plus one =[ Personal, only I see it ]= row. The meaningless "personal scope + shared" combination is absent (no team to share with). Default (topmost) is =[ Team: <X>, visible to the team ]= when the filter has =:team X=, else =[ Personal, only I see it ]=. Most-common-on-top per the pattern in =pearl--with-sentinel= callers. Cancel via =[ Cancel. ]=. +3. If the chosen name already exists as a view in the chosen scope: prompt =Replace? Rename? Cancel?=. =Replace= updates the existing view's =filterData= (and =shared= / =teamId= if they changed). =Rename= prompts for a different name. =Cancel= aborts the sync. +4. Pass the entry's =:filter= plist through =pearl--build-issue-filter= -- the existing compiler already produces an =IssueFilter=, which is exactly the type =customView.filterData= accepts. No new mapping helper needed; the existing compiler IS the mapping. +5. Call =customViewCreate= (first-time) or =customViewUpdate= (already synced) with the team id, shared flag, and filterData derived from the picked end-state row. +6. On success: store the returned id, team-id, and shared into the entry, persist via =customize-save-variable=, message "Synced NAME to Linear as <end-state label>". +7. On failure: leave the entry untouched, message the API error. + +Bind under =C-; L f S= (capital S for sync, parallel to =s= for save). Also expose in the transient under Fetch as ="S" "sync saved query to Linear"=. + +*** New: =pearl-publish-current-source= + +Convenience wrapper: read the active buffer's =#+LINEAR-SOURCE=, if it's a =:type filter= with a name (i.e. the buffer is rendering a local saved query), sync that query up. Saves a step when the user is reading a query and decides "this should be a view." + +Skip if the source is a view (already on Linear) or a transient filter (no name to sync under). + +Bind: =C-; L f P= (capital P for Publish, parallel to lowercase =f p= = by-project; lives next to =f S= = sync-saved-query in the Fetch/source-ops sub-group). The transient entry letter is implementation-time detail — propose =U= ("upload") in Fetch since =P= is already pick-source in the transient's flat namespace. The fetch sub-group is increasingly "source operations" (run + sync + publish) rather than pure fetch; reshaping the prefix is the [#B] keybinding-shape review task, not this spec's job. + +*** Extended: =pearl-delete-saved-query= + +If the chosen entry has a =:linear-view-id=, add a second prompt after the existing "Delete saved query X?" confirmation: + +="Also delete the linked Linear view (\"NAME\")? (yes/no)"= + +- =yes= → call =customViewDelete= with the id, then delete locally. If the delete call fails, surface the error and ask whether to delete locally anyway. +- =no= → unlink only (drop the =:linear-view-id= / =:linear-view-team-id= / =:linear-view-shared= keys), then delete locally. The view stays on Linear. + +For local-only entries (no =:linear-view-id=), behavior is unchanged. + +Note: Linear's API exposes =customViewDelete= but not =customViewArchive=. Custom views aren't an archive-able entity in the API surface, so the choice is delete-or-unlink, not archive-or-unlink. =customViewDelete='s recoverability is whatever Linear's backend provides for the entity (likely trash with a recovery window per the standard Linear pattern). + +*** Extended: =pearl-pick-source= candidate label + +Today the picker shows local saved queries as =[saved] Name=. Add a third label format for synced ones that carries the scope, so the user reads provenance + destination in one scan: + +| State | Label format | +|------------------------------------------+------------------------------------| +| Local-only saved query | =[saved] Name= | +| Synced saved query (team-scoped) | =[saved → Engineering] Name= | +| Synced saved query (personal) | =[saved → Personal] Name= | +| View favorite | =[view] Name= | + +The arrow reads "where it lives now." The team name (or =Personal=) is derived from the entry's =:linear-view-team-id= via =pearl--team-name-by-id=, with =Personal= as the fallback when =:linear-view-team-id= is nil. The shared flag is not surfaced in the label -- at picker time the user is *running* the view, not editing its visibility, so the destination scope is the load-bearing fact. (Shared is visible at sync time and on the next sync prompt.) + +This follows the same enrichment pattern as the Q2 collapsed scope prompt: each label conveys both kind and the key state metadata. The cost is wider rows for synced entries; the win is no second action needed to see where the view lives. + +Dispatch logic: a =[saved → ...]= entry uses =pearl-run-view= against the stored =:linear-view-id=, so refresh and pagination respect any Linear-side filter drift. A =[saved]= entry (local-only) still uses the local filter. + +** Filter mapping: pearl plist → Linear customView.filterData + +*Resolved by 2026-05-28 live-API probe:* =CustomViewCreateInput.filterData= is type =IssueFilter= -- the exact same type pearl's existing =pearl--build-issue-filter= produces for the =issues(filter:)= query. The mapping is therefore a pass-through. No new helper is needed; the compiler IS the mapping. + +#+begin_src elisp +;; The sync command builds filterData via: +(pearl--build-issue-filter (plist-get entry :filter)) +#+end_src + +Every dimension pearl currently authors (=:open=, =:state=, =:project=, =:cycle=, =:label-id=, =:labels=, =:assignee=, =:assignee-id=, =:team=) is already accepted by =IssueFilter=. The full mapping table that earlier appeared here collapses to one line. + +This is a substantial spec simplification surfaced by the probe. The original Implementation phase 1 (=feat(view-sync): map pearl filter plist to customView filterData JSON= -- pure helper plus unit tests) becomes trivial -- pearl already has that helper -- and the unit tests for it already exist under test-pearl-filter.el. Adjust the phase 1 task: instead of writing a new helper, the sync command consumes the existing one. + +*** Sort and order + +*Resolved by 2026-05-28 probe:* =CustomViewCreateInput= has no =sortOptions=, =sortField=, =sortBy=, or equivalent input. Linear views appear to manage sort through their own UI rather than through the API surface this spec touches. v1 therefore does not sync =:sort= or =:order=; the synced view sorts however Linear's defaults render it. + +This is a v1 cost the user has to accept: a saved query that depends on a specific sort order won't have that order applied when run as the synced view. Surface in the README ("synced views use Linear's default sort"). Sort-sync moves to vNext, contingent on Linear adding a sort input or pearl finding the input through a different mutation surface. + +** Conflict handling: name collisions + +Linear allows duplicate view names within a team (it disambiguates by id). Pearl is stricter at sync time: prompt =Replace? Rename? Cancel?= on any existing same-name view in the chosen scope. The friction is intentional. Auto-naming around collisions ("View (2)") produces views the user didn't name and has to clean up later. An explicit choice is cheaper. + +Replace updates by id (=customViewUpdate=); the existing view's id is preserved, so anyone with that view favorited keeps it. Rename re-prompts. Cancel aborts cleanly. + +** Conflict handling: remote drift on update + +Sync-update (the user re-syncs a query they've already pushed) overwrites the view's filterData with the local plist. This is one-way push by design: pearl is the editor, Linear is the publishing target. If Craig edited the view in Linear since the last pearl sync, the next pearl sync clobbers those edits with the local plist. + +This is acceptable for v1 because the user is explicitly invoking sync-up; the verb is "push my version." If we want a safety prompt later ("the Linear view was edited 3 days ago; overwrite?"), it can layer on top via a remote-=updatedAt= check before the update. + +** Account scope + +v1 targets the currently-active Linear account (single-account world). Multi-account support ([[file:multi-account-spec.org][multi-account-spec.org]]) adds a per-saved-query =:account= field. Sync-up will need to record the account at sync time so a later re-sync goes to the right workspace; until multi-account ships, the active account is implicit and the field stays nil. + +* Files touched + +- =pearl.el= — new helpers and commands described above. Estimated ~150 lines plus tests. +- =docs/saved-query-sync-spec.org= — this file. +- =README.org= — new subsection under Sources describing sync-up, the picker label conventions, and the archive-on-delete prompt. +- =tests/test-pearl-saved-query-sync.el= (new) — unit tests for the filter-to-view mapping, the entry-extension shape, the dispatch logic in the picker, and the archive-on-delete branch. + +* Migration + +None required. Existing =pearl-saved-queries= entries have no =:linear-view-id=, so all dispatch and delete paths fall through to today's behavior. Sync is opt-in per entry. + +If an entry's filter shape uses a key the v1 mapping doesn't support, sync-up refuses cleanly ("can't represent X dimension in view filterData") and leaves the entry as local-only. + +* Open questions (for Craig) + +All questions resolved; see § Review dispositions. Spec status moves to *Ready*. + +* Acceptance criteria + +1. =pearl-sync-saved-query-to-linear= successfully creates a Linear custom view from a local saved query (one round trip against the live API, verified by reading the view back via =pearl-run-view= and checking the issue list matches). +2. Re-running sync on an already-synced query updates the linked view's filter in place (same view id, new filterData). +3. =pearl-delete-saved-query= on a synced entry offers the archive prompt; =yes= archives, =no= unlinks only, =cancel= aborts both. +4. =pearl-pick-source= shows =[saved↑]= for synced entries, =[saved]= for local-only, =[view]= for view favorites; dispatch routes correctly for each. +5. Name-collision prompt fires when sync-up names a view that already exists in the chosen scope. +6. Filter-shape mapping covers every dimension pearl currently authors; a filter using an unsupported dimension (none exist today, but a future =:priority= could) refuses with a clear error rather than silently dropping the dimension. +7. Sort and order map per the table above. +8. README has the new subsection. The spec status moves to =Done= and the closing commit message links it. + +* Implementation phases (commits) + +1. *=feat(view-sync): wire pearl--build-issue-filter as the customView.filterData producer=* — confirmed by the 2026-05-28 probe that =filterData= is type =IssueFilter=, so this phase is now a trivial sanity-test: round-trip a few representative filter plists through the existing compiler and assert the output shape matches =CustomViewCreateInput.filterData=. No new helper. +2. *=feat(view-sync): pearl-sync-saved-query-to-linear command + transient entry=* — the user-facing command, team-scope prompt, shared-flag prompt, replace/rename/cancel collision handling, schema-extension on the saved-query entry. +3. *=feat(view-sync): extend pearl-delete-saved-query with archive prompt=* — the archive-or-unlink branch for synced entries. +4. *=feat(view-sync): pearl-pick-source distinguishes [saved] vs [saved↑] vs [view]=* — label and dispatch updates. +5. *=feat(view-sync): pearl-publish-current-source convenience wrapper=* — read =#+LINEAR-SOURCE=, route to sync-up if it's a named saved-query filter. +6. *=docs(view-sync): README Sources section gets the sync-up + picker-label conventions=*. + +Each commit is independently reviewable; (1) lands the load-bearing helper, (2-5) layer commands on top, (6) closes the loop. + +* Implementation tasks (drop-in for todo.org) + +The full list of =todo.org= entries that fully implementing and testing this spec would require. Copy-paste into =todo.org='s =* Pearl Open Work= header once the spec is approved. Each entry is independently shippable per the commit decomposition above. Tags follow [[file:../../rulesets/claude-rules/todo-format.md][todo-format.md]]. + +#+begin_src org +,** TODO [#B] view-sync: confirm pearl--build-issue-filter output matches customView.filterData :feature:quick:solo: +Probe confirmed =filterData= is type =IssueFilter= -- pearl already produces it. This phase is a sanity-test commit: round-trip a few representative filter plists through =pearl--build-issue-filter=, assert each output matches the shape =CustomViewCreateInput.filterData= accepts (via a live single-create call against a throwaway view that gets deleted on tear-down). No new helper. Spec § Filter mapping. + +,** TODO [#B] view-sync: pearl-sync-saved-query-to-linear + transient entry :feature:next: +User-facing command under =C-; L f S= (and ="S"= in the transient's Fetch group). Reads a saved query, runs the collapsed enriched scope-and-visibility prompt (default = =[ Team: <filter-team>, visible to the team ]= if filter has =:team=, else =[ Personal, only I see it ]=), handles Replace/Rename/Cancel on name collision, calls =customViewCreate= or =customViewUpdate=, writes =:linear-view-id= / =:linear-view-team-id= / =:linear-view-shared= / =:linear-view-synced-at= back to the entry. On the rare =customize-save-variable= failure after a successful API call, the error message must name the orphan view's id explicitly so the user can find it in Linear or re-sync with Replace. Spec § User-facing command surface, § Review disposition Q6. + +,** TODO [#B] view-sync: extend pearl-delete-saved-query with delete-on-Linear prompt :feature:quick:solo: +Add a second confirmation for entries carrying =:linear-view-id=: delete the linked view on Linear (=customViewDelete=), unlink only, or cancel. Local-only entries unchanged. Spec § User-facing command surface → Extended: pearl-delete-saved-query. Probe confirmed Linear has =customViewDelete= but no =customViewArchive=. + +,** TODO [#B] view-sync: pearl-pick-source distinguishes [saved] / [saved↑] / [view] :feature:quick: +Picker label gains the synced-vs-local marker; dispatch routes =[saved↑]= entries through =pearl-run-view= against =:linear-view-id= rather than re-running the local filter. Spec § Picker label + § Dispatch logic. + +,** TODO [#B] view-sync: pearl-publish-current-source + binding :feature:quick:solo: +Read the active buffer's =#+LINEAR-SOURCE=; if it's a named filter source matching a local saved query, route to sync-up. Skip if it's a view or a transient filter. Bind under =C-; L f P= (capital P for Publish, parallel to =f p= = by-project). Transient entry letter to be picked at implementation time (=P= is taken; lean toward =U= for "upload"). + +,** TODO [#B] view-sync: README Sources section gets sync-up + label conventions :feature:quick:solo: +New README subsection under Sources covering: how to sync a saved query up, the picker-label triad ([saved] / [saved↑] / [view]), the archive-on-delete prompt, and the one-way push model. Spec status moves to =Done=. + +,** TODO [#B] view-sync: tests + manual verify checklist for sync flow :test:solo: +Unit tests for the filter-to-filterData mapping, the entry-extension shape, the dispatch logic, and the archive-on-delete branch. Plus manual-verify entries under "Manual testing and validation" mirroring the spec's =Acceptance criteria=: create-view round-trip, update-in-place, archive-vs-unlink, picker label triad, name-collision prompt, mapping refuse on unsupported dimension, sort/order mapping. Each test entry as its own =*** VERIFY= under the parent so resolution follows the [[file:../../rulesets/claude-rules/verification.md][verification.md]] flow. +#+end_src + +* Out of scope (vNext) + +- Two-way sync (pull view changes back into a saved query as a snapshot). +- Bulk sync ("publish every local saved query"). +- Sharing-mode toggles (private view, team view, organization-wide view) beyond the v1 personal/team binary. +- Sync of saved-query =:sort= / =:order= changes after the initial sync (v1 syncs them at create-or-update time, no per-field diffing). +- A "last synced at" indicator in the picker. +- Conflict detection on update (Linear view edited since last sync) — v1 is push-overwrites; the safety prompt is a vNext layer. +- Multi-account sync routing (covered by the multi-account spec). + +* Review dispositions + +** 2026-05-28 — Q1 (Linear API shape) and Q4 (archive vs delete): resolved by live-API probe + +Ran an introspection probe against =https://api.linear.app/graphql= with Craig's API key. Findings: + +- Mutations on the Linear schema: =customViewCreate=, =customViewUpdate=, =customViewDelete=. *No =customViewArchive=.* Q4 resolves to delete-only. +- =CustomViewCreateInput.filterData= is type =IssueFilter= -- the exact type pearl's existing =pearl--build-issue-filter= already produces. Q1 resolves favorably: no new mapping helper is needed, the existing compiler IS the mapping. +- Scope fields on the input: =teamId= (optional), =projectId= (optional), =initiativeId= (optional), plus a separate =shared: Boolean= flag. v1 surfaces team + shared only; project/initiative scope moves to vNext. +- No sort/order input on =CustomViewCreateInput= or =CustomViewUpdateInput=. v1 doesn't sync sort/order; synced views use Linear's defaults. Moves to vNext. +- =CustomViewUpdateInput= is symmetric with Create minus the id (which is the targeting arg). All fields optional, so partial updates work (update just =filterData=, leave name/team alone). + +Effect on the spec: § Filter mapping collapses to a one-liner. § Extended pearl-delete-saved-query renames "archive" → "delete." § Sort and order moves the sync intent to vNext. Schema extension adds =:linear-view-shared=. Implementation phase 1 trivializes (no new helper). New open question Q7 (shared default) surfaced by the probe. + +** 2026-05-28 — Q2 (personal-vs-team default) and Q7 (shared default): collapsed into one enriched prompt + +Craig (2026-05-28): Q2's option 1 — default to the team named in the filter's =:team= if present, else personal. Plus the broader observation: "have we informed the user if this choice is consequential in any way? this almost feels as if we can collapse two levels together by adding more information to each individual choice." + +Resolution: collapse the team-scope prompt and the shared-flag prompt into one enriched prompt where each candidate spells out the complete end-state. Most-common-on-top per the existing =pearl--with-sentinel= pattern. The default candidate becomes =[ Team: <filter-team>, visible to the team ]= when the filter has =:team= (publishing-a-team-view being the typical sync intent), or =[ Personal, only I see it ]= when not. The meaningless "personal scope + shared" combination is absent from the candidate list. + +Effect on the spec: § User-facing command surface → New: pearl-sync-saved-query-to-linear, steps 2 and 3 collapse into one enriched prompt; § Open questions, Q2 and Q7 close. This is also a fifth catalog candidate for the rulesets pattern discussion: *collapse N orthogonal prompts into one enriched prompt where each candidate is a complete end-state*, sibling to "the prompt label matches what the prompt does" and "default the most-common choice." + +** 2026-05-28 — Q6 (failure handling on partial sync): surface + log + idempotent re-sync + +Recommendation accepted: option 1. On =customViewCreate= success followed by =customize-save-variable= failure, pearl messages the user with the orphan view's id ("view created on Linear as ABC... but local link couldn't be saved; re-run pearl-sync-saved-query and pick Replace to reconcile"). The next sync run on the same entry hits the existing-name collision prompt, and Replace updates the orphan to match. The orphan is recoverable through normal flow. + +Rationale: the failure mode is rare (=customize-save-variable= failing means disk/hook trouble, which is unusual and the user will know about it from other signals); the recovery path uses the same flow as an explicit re-sync (idempotent); options 2-4 add API calls, state machines, or persist-then-mutate patterns to defend against an unlikely failure where the user can already recover. Add to the spec's Implementation phase 2 task description that the error message must name the orphan id explicitly so the user can find the view in Linear if Replace isn't desired. + +Effect on the spec: § Open questions empties (status moves to Ready). § Implementation tasks: phase 2's entry adds a one-line note about the orphan-id messaging requirement. + +** 2026-05-28 — Phase 1 round-trip probe: pearl filter output works as customView filterData + +Ran a live round-trip against api.linear.app to close phase 1: + +1. =pearl--build-issue-filter '(:open t :assignee :me)= → + =(("assignee" ("isMe" ("eq" . t))) ("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))= +2. =customViewCreate= called with that structure as =filterData=, =shared: false=, no team. Result: =success=t=, view created with id =4354f3e7-1f94-4ea7-8ba8-6f24d3ee2944=. +3. =customView(id:)= read back the identical =filterData= structure. Linear preserved the shape exactly. +4. =shared=false= and =team=nil= persisted as personal, as expected. +5. =customViewDelete= cleaned up the throwaway view (=success=t=). + +Phase 1 is therefore complete. Pearl's existing compiler is the mapping; no new helper or schema-translation layer is needed. Phase 2 (the user-facing command) can consume =pearl--build-issue-filter='s output as =filterData= verbatim. + +Probe script archived at =/tmp/view-sync-probe.el= (not committed; one-off verification). The probe pattern (build via compiler → create → read back → delete) is what phase 7's slow-tagged integration test will codify if Craig wants a permanent regression guard. + +** 2026-05-28 — Q5 (pearl-publish-current-source v1 vs vNext): ship in v1 + bind + +Craig (2026-05-28, revised same-day): option 3 -- ship the convenience wrapper in v1 *and* bind it. The trigger ("I'm reading this saved query rendered in pearl right now and want to publish it") deserves a one-chord answer rather than an =M-x= round-trip; if we're shipping the wrapper anyway, binding it lands the muscle-memory shape at the same time. + +Effect on the spec: § User-facing command surface → New: pearl-publish-current-source gains =C-; L f P= as the binding (capital P for Publish, parallel to lowercase =f p= = by-project). The transient entry letter is implementation-time detail since =P= is taken in the transient's flat namespace; tentative =U= for "upload" in Fetch. Implementation phase 5's task description updated to include the binding. The fetch sub-group is now closer to "source operations" (run + sync + publish) than pure fetch; the verb-prefix shape revisit is the existing [#B] keybinding-shape-review task, not this spec. + +** 2026-05-28 — Q3 (picker marker): enriched label =[saved → <scope>]= + +Craig (2026-05-28): option 2 -- enriched label showing the destination scope, applying the same pattern as the Q2 collapse. Synced saved queries render as =[saved → Engineering] Name= or =[saved → Personal] Name= in =pearl-pick-source=. Local-only stays =[saved]=. View favorites stay =[view]=. The shared flag isn't surfaced at picker time (the user is running, not editing). + +Effect on the spec: § Extended pearl-pick-source candidate label gets the new table. Implementation phase 4's task description and acceptance criteria mention the new label format. Q3 closes. diff --git a/docs/specs/ticket-save-model-spec.org b/docs/specs/ticket-save-model-spec.org new file mode 100644 index 0000000..dd99add --- /dev/null +++ b/docs/specs/ticket-save-model-spec.org @@ -0,0 +1,255 @@ +#+TITLE: pearl — Unified Ticket Save Model & Keybinding Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*SHIPPED. Superseded in part by [[file:ticket-save-model-v2-spec.org][ticket-save-model-v2-spec.org]] — v2 folds the structured fields (priority, state, assignee, labels) onto this save engine and removes immediate push, reversing the "field setters stay immediate" half of decision 8. The compose-buffers-stay-direct half of decision 8 still stands (v2 doesn't touch the compose buffers). The engine, dirty-scan, outcome contract, and keybinding scheme described here are live; read v2 for the structured-field write path.* + +*Review incorporated through round 2 (Codex, 2026-05-24). Implementation-ready pending Craig's final go.* Round 1's six blocking findings and round 2's comment-ownership blocker are dispositioned and folded into the body; modified recommendations are recorded under "Review dispositions". Open questions are resolved into "Agreed decisions"; none remain blocking. + +Covers two coupled changes: a unified "save the ticket" model that replaces the per-field sync commands as the primary editing path, and an opt-in keybinding scheme organized around it. Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (the field provenance hashes this builds on), [[file:issue-conflict-handling-spec.org][issue-conflict-handling-spec.org]] (the conflict gate each field runs through), and [[file:multi-account-spec.org][multi-account-spec.org]] (the viewer identity the comment save depends on is per-account). + +* Problem + +Editing a ticket today means picking the right per-field command: + +- Edit the heading, run =pearl-sync-current-issue-title=. +- Edit the body, run =pearl-sync-current-issue= (or compose it via =pearl-compose-current-description=). +- Edit a comment in place, run =pearl-edit-current-comment=. + +Three free-text fields, three commands, three things to remember. There's no single "I edited this ticket, save it" action, and no way to edit several fields (or several tickets) and push them all at once. In practice the save command would be the most-used command in the package, yet every field command currently has its own keybinding competing for finger memory. + +* Current State + +- *Provenance hashes* live in each issue's drawer and already encode "did this change since fetch": + - =LINEAR-TITLE-SHA256= — hash of the displayed (bracket-stripped, title-cased, prefix-excluded) title. + - =LINEAR-DESC-SHA256= — hash of the description *markdown* (Linear stores markdown; this is the remote-conflict baseline). + - =LINEAR-DESC-ORG-SHA256= — hash of the rendered *Org* body. This exists precisely because the markdown round-trip is lossy: =pearl--subtree-dirty-p= uses it so a clean ticket whose content doesn't survive =org->md= round-trip isn't falsely flagged dirty. + - per comment: =LINEAR-COMMENT-SHA256= + =LINEAR-COMMENT-ID=. +- *Each per-field command already does the diff.* =pearl-sync-current-issue= hashes the local body, compares, short-circuits to a no-op when unchanged; otherwise fetches the remote, runs the pure three-way =pearl--sync-decision= (=:noop= / =:push= / =:conflict=), and dispatches through =pearl--commit-sync-decision=. Title and comment sync mirror this, sharing the gate. *But these are interactive commands that message from async callbacks* — they take no callback, return no outcome, and don't expose whether a field was pushed, unchanged, conflicted, or cancelled. +- *Conflict resolution* (=pearl--resolve-conflict=) offers cancel / use-local / use-remote, plus an smerge rewrite buffer whose result arrives later via =pearl--conflict-commit= / =pearl--conflict-abort=. +- *Field setters* (=pearl-set-priority= / =-state= / =-assignee= / =-labels=) push immediately via =pearl--push-issue-field=. Nothing to diff — the command *is* the edit. +- *Viewer identity* (=pearl--viewer-async=, cached in =pearl--cache-viewer=) backs =pearl--comment-editable-p= — comment editing is gated to the viewer's own comments. +- *Subtree iteration* exists: =pearl--issue-subtree-markers= walks every issue heading (used by the merge refresh). +- *Discoverability* is the transient =pearl-menu=. + +* Proposed Design + +** Architecture: a small save engine, not wrappers over interactive commands + +The interactive commands message from callbacks and can't be orchestrated by reading side effects. The implementation is a layered save engine: + +1. *Pure/local dirty scanners* — =pearl--issue-dirty-fields= returns which of title / description / own-comments changed, with no network calls. +2. *Per-field async savers* — each accepts a marker + a callback and emits one structured outcome (below). These hold the fetch + three-way gate + push logic currently inside the interactive commands. +3. *A sequential queue runner* — drives a list of per-field savers one at a time, collecting outcomes. +4. *Thin interactive wrappers* — the existing =pearl-sync-current-issue= / =-title= / =pearl-edit-current-comment= become thin wrappers over the per-field savers (so they keep working and gain the structured outcome), and =pearl-save-issue= / =pearl-save-all= are wrappers over the queue runner. + +The field setters stay separate — they are immediate mutations, not free-text saves. + +** Dirty detection + +=pearl--issue-dirty-fields= over the issue subtree at point: + +- *Title* dirty if =secure-hash= of the displayed (prefix-stripped) title ≠ =LINEAR-TITLE-SHA256=. +- *Description* dirty if =secure-hash= of =pearl--issue-body-at-point= (the rendered Org) ≠ =LINEAR-DESC-ORG-SHA256=. *Fall back* to =org->md= vs =LINEAR-DESC-SHA256= only for legacy rendered subtrees that predate the Org hash. This avoids the false-dirty trap: scanning via the markdown hash would mark clean-but-lossy tickets dirty and fire pointless fetches. The remote *conflict gate* still hashes markdown against =LINEAR-DESC-SHA256=, because Linear stores markdown. +- *Comment* dirty detection is *two-phase* because ownership needs the viewer id, which the local scan doesn't have: + - *Phase A — changed candidates (local only).* A comment is a /changed candidate/ if =secure-hash= of =pearl--org-to-md= of its body ≠ its =LINEAR-COMMENT-SHA256=. The hash must run through =org->md= because =LINEAR-COMMENT-SHA256= is taken over the *markdown* Linear stored (the comment renders via md→org), matching exactly how =pearl-edit-current-comment= computes its no-op check. Hashing the raw Org body would be wrong. + - *Phase B — ownership classification.* Only if changed candidates exist, resolve the viewer once (below) and classify each candidate as an /own dirty comment/ (queued to push) or =skipped= / =read-only= (a non-own / bot / external comment edited locally — reported, not silently dropped). + +On a successful description push, advance *both* =LINEAR-DESC-SHA256= and =LINEAR-DESC-ORG-SHA256= so the next scan is lossless. + +** Internal save-outcome contract + +Every per-field saver invokes its callback exactly once, only after its *final* outcome is known, with this plist: + +#+begin_src emacs-lisp + (:issue-id "uuid" + :identifier "ENG-123" + :field title | description | comment + :comment-id "comment-id-or-nil" + :status pushed | unchanged | conflict | resolved-remote | skipped | failed + :reason nil | read-only | viewer-unavailable | missing-property + | fetch-failed | push-failed | cancelled | aborted + :label "ENG-123 description" + :message "human-readable detail") +#+end_src + +=:status= is the small machine-testable set; =:reason= carries the why for the non-success cases. =save-issue= and =save-all= aggregate *only* these outcomes — never message-scraping. + +** Sequential queue semantics + +The queue runner starts the next dirty field only after the current field's callback has fired. This guarantees at most one conflict-resolution UI is live at a time. Outcome timing per resolution path: + +- *No remote difference, clean push* → =pushed= when the update callback returns (=failed= / =push-failed= if it errors). +- *Unchanged* → =unchanged=, no network beyond the dirty scan's already-known state. +- *Conflict, user cancels the prompt* → =conflict=, =:reason cancelled= (the field is still in conflict; cancelling didn't resolve it). +- *Conflict, use-local* → =pushed= (or =failed= / =push-failed=) after the overwrite push returns. +- *Conflict, use-remote* → =resolved-remote= after Linear's text is applied locally and the hashes advance (no push). +- *Conflict, smerge rewrite* → the saver's callback does not fire until =pearl--conflict-commit= (=pushed=/=failed=) or =pearl--conflict-abort= (=conflict=, =:reason aborted=). The queue does *not* advance to the next field while an smerge buffer is pending. + +** Comment ownership and viewer lookup + +Comment saving depends on viewer identity, which is async. Ownership classification (Phase B above) needs it, so the viewer is resolved *once per save* — not once per comment: + +- Resolve the viewer *once* when changed comment candidates exist: at the start of =pearl-save-issue= (before queueing), and once for the whole batch in =pearl-save-all=. Reuse =pearl--cache-viewer= when warm; a cold cache costs one read-only lookup. +- If viewer resolution *fails*, skip comment edits (=skipped=, =:reason viewer-unavailable=) and still let title/description save proceed. +- Non-own / bot / external comments edited locally → =skipped=, =:reason read-only=, surfaced in the summary so the user understands why their edit didn't push. + +*Multi-account is not a prerequisite.* The current single-account implementation uses the existing global =pearl--cache-viewer=. The reference to [[file:multi-account-spec.org][multi-account-spec.org]] is forward-looking only: when multi-account lands, account switching must invalidate or scope the viewer cache as that spec specifies. This feature does not wait on it. + +** =pearl-save-issue= — diff the ticket at point, push only what changed + +Run from anywhere inside an issue subtree: + +1. Local dirty scan (no network). If nothing's dirty, report "nothing to save" and stop — no fetch. +2. Resolve the viewer once if comments are dirty. +3. Queue the dirty fields and run them sequentially. +4. Report one summary grouping the outcomes: =Saved ENG-1: 1 title, 1 description pushed; 1 comment skipped (read-only); 2 unchanged=. + +No confirmation prompt — =save-issue= is issue-scoped and explicit (you ran it on this ticket). Each field's content is re-read and re-gated at push time, so the scan picks the work list and the push validates against the live remote. + +** =pearl-save-all= — every ticket in the file, confirmed, in one pass + +A single key must not silently push many remote mutations. The guarantee is *no remote mutation before the user confirms* — relaxed from "no remote calls" to allow the one read-only viewer lookup needed to count read-only comment skips accurately. So: + +1. *Local dirty scan first* across all =pearl--issue-subtree-markers= — no network for title/description; comment changed-candidates found locally (Phase A). +2. If nothing's dirty, report "nothing to save" and stop — no network at all. +3. *If changed comment candidates exist and the viewer cache is cold, run one read-only viewer lookup* (not a mutation) so read-only comments can be classified and counted. If that lookup fails, proceed to confirm title/description and state that comments will be skipped (viewer unavailable). +4. *Prompt once* before any mutation, naming the scope by field type: =Save 7 fields across 3 Linear issues? (2 titles, 4 descriptions, 1 comment; 1 read-only comment skipped)=. Declining does no further fetch and no mutation. +5. On confirm, run the queue across all dirty fields, continuing *past* a per-ticket conflict (that field is left untouched and reported; the rest still save). +6. Progress messages name the current issue/field; the final report aggregates =pushed / unchanged / skipped / conflict / failed= with reasons for the skips and failures. + +*Snapshot rule:* the dirty-field work list is captured at the initial scan. Edits made *after* the scan aren't included until the next save. (Content is still re-read and re-gated at each field's push, so a stale snapshot can only under-include, never push the wrong text.) A malformed or missing drawer on one issue is counted =failed= / =skipped= with a useful label, never aborts the batch. + +** Field setters stay immediate + +=set-priority= / =-state= / =-assignee= / =-labels= do not fold into =save-issue= — no free-text state to diff, and they already push on selection. They keep immediate behavior and are surfaced under the edit prefix for discoverability only. + +** Compose buffers stay direct-push (v1) + +=pearl-compose-current-description= and the interactive =pearl-add-comment= keep their explicit =C-c C-c= submit-and-push behavior. Deferring a composed-but-unsent comment to =save-issue= would need a new local "unsent comment" representation before Linear assigns a comment id — a separate design. The unified save covers *in-place* edits; the compose buffers remain the focused-editing path that pushes on submit. + +** Keybinding scheme — opt-in =pearl-prefix-map= + +The package does *not* bind a global prefix at load time (=C-;= isn't reliably free across terminals/GUIs, and auto-installing a multi-key global prefix is a compatibility decision the user should own). Instead it *defines* a prefix keymap the user binds: + +#+begin_src emacs-lisp + (define-prefix-command 'pearl-prefix-map) + ;; ... pearl populates pearl-prefix-map with the bindings below ... + + ;; user config — pick a prefix that's free in your setup: + (global-set-key (kbd "C-; L") pearl-prefix-map) + ;; or, with use-package: + ;; :bind-keymap ("C-; L" . pearl-prefix-map) +#+end_src + +No imperative installer is shipped — the =define-prefix-command= + documented binding snippet is the idiomatic, package-safe path. The README documents =C-; L= as a *suggested* prefix, not a default. Map contents (mnemonic: add / delete / edit): + +#+begin_example +<prefix> + a add a t add ticket (pearl-new-issue) a c add comment + d delete d t delete ticket (pearl-delete-current-issue) + e edit/save e e save this ticket (pearl-save-issue) ← primary + e a save all tickets (pearl-save-all) + e d compose description (pearl-compose-current-description) + e c edit comment (pearl-edit-current-comment) + e p set priority e s set state e n set assignee e l set labels + m menu the full transient (pearl-menu) +#+end_example + +The fetch/view/setup commands stay on the transient, reached with =m=. The transient is the discovery surface; the keymap is muscle memory. + +** Transient menu changes + +The "Issue at point" group loses the two field-sync entries (subsumed by save) and gains the save commands: + +| Before | After | +|--------+-------| +| =e= Edit desc → push (=sync-current-issue=) | =e= *Save ticket* (=pearl-save-issue=) | +| =t= Edit title → push (=sync-current-issue-title=) | =E= *Save all* (=pearl-save-all=) | +| =s= Set state | =s= Set state | +| =a= Set assignee | =a= Set assignee | +| =P= Set priority | =P= Set priority | +| =L= Set labels | =L= Set labels | +| =c= Add comment | =c= Add comment | +| =M= Edit comment | =M= Edit comment | +| =D= Compose desc → push | =D= Compose desc → push | +| =k= Delete issue | =k= Delete issue | +| =o= Open in browser | =o= Open in browser | + +(The transient and the =pearl-prefix-map= are independent surfaces; the table above is the transient. The menu's existing key locks in transient tests, so this is the exact target.) + +* Agreed Decisions (This Feature) + +1. A layered save engine: pure dirty scanners → per-field async savers (marker + callback → structured outcome) → sequential queue runner → thin interactive wrappers. +2. Description dirty detection uses =LINEAR-DESC-ORG-SHA256= first, falling back to the markdown hash only for legacy subtrees; a push advances both hashes; the remote gate still uses the markdown hash. +3. One structured field-outcome plist everywhere; =:status= ∈ =pushed|unchanged|conflict|resolved-remote|skipped|failed=, detail in =:reason= / =:message=. +4. Dirty fields push sequentially; the queue never advances while a conflict-resolution buffer is pending; cancel/abort report =conflict= with a reason. +5. Comment dirty detection is two-phase: Phase A finds changed candidates locally via =hash(org->md body) ≠ LINEAR-COMMENT-SHA256=; Phase B resolves the viewer once (only when candidates exist) and classifies own-dirty vs =skipped/read-only=. Viewer failure skips comments but lets title/description proceed. Multi-account is not a prerequisite — single-account uses the global =pearl--cache-viewer=. +6. =save-all='s guarantee is no remote *mutation* before confirmation. It scans dirty fields locally, may run one read-only viewer lookup pre-confirmation (when comment candidates exist and the cache is cold) so the prompt counts read-only skips accurately, then prompts once naming counts; declining mutates nothing. =save-issue= skips confirmation (issue-scoped) but keeps the local no-op fast path. +7. =save-all= snapshots the dirty work list at scan time; later edits wait for the next save; per-field content is re-read and re-gated at push. +8. Field setters stay immediate; compose buffers stay direct-push in v1. +9. Keybindings ship as an opt-in =pearl-prefix-map= (no global bind at load); README documents =C-; L= as a suggested binding. Verb layout =a= / =d= / =e= + =m=. +10. The transient "Issue at point" group is retargeted per the table above; the per-field sync commands stay callable but lose dedicated keys. + +* Files Touched + +- =pearl.el=: =pearl--issue-dirty-fields= (local scanners); per-field async savers extracted from the current interactive commands; the sequential queue runner; =pearl-save-issue= / =pearl-save-all=; the interactive sync commands re-pointed as thin wrappers; =pearl-prefix-map= (define-prefix-command, populated, not bound); the retargeted =pearl-menu= group; description-push advancing both hashes. +- =docs/=: this spec. +- =README.org=: the save model + the suggested-binding snippet, with the existing title-bracket and markdown-lossiness warnings kept *near* the save docs (a unified save pushes title + description together, so the losses surface in one command). + +* Test Plan + +New focused =tests/test-pearl-save.el= (rather than scattering into the per-field files): + +*Dirty Scan* +- Clean rendered issue whose markdown is lossy under =org->md= → empty (via =LINEAR-DESC-ORG-SHA256=). +- Legacy issue lacking the Org hash → falls back to the markdown hash. +- Reports title-only, description-only, own-comment-only, multiple own comments, and mixed title+description+comment. +- Comment candidate detection hashes =org->md= of the body (matching =LINEAR-COMMENT-SHA256=): a comment edited only in ways that survive the md round-trip is dirty; a clean comment is not flagged. +- Phase A (candidates) runs with no viewer lookup; Phase B classifies own vs read-only only when candidates exist; no candidates → no viewer lookup at all. +- Edited non-own comment → classified =skipped/read-only=, not dirty-to-push. + +*save-issue (HTTP stubbed)* +- Clean ticket → no fetch/update calls. +- Pushes dirty fields sequentially; records ordered outcomes. +- Per-field conflict reported; that field's hash untouched; other dirty fields still push. +- Two conflicts → the second prompt doesn't open until the first resolution callback completes. +- Smerge rewrite delays the next field until =conflict-commit= / =conflict-abort=. +- Push failure keeps local text + stored hash; summary reports =failed/push-failed=. +- Description push advances both =LINEAR-DESC-SHA256= and =LINEAR-DESC-ORG-SHA256=. +- Viewer lookup failure → title/description proceed, comments =skipped/viewer-unavailable=. + +*save-all* +- Scans all markers locally; with comment candidates and a cold viewer cache, runs exactly one read-only viewer lookup before the prompt, and zero mutations; declined confirmation does no mutation. +- No comment candidates → no viewer lookup before the prompt; clean file → no network at all. +- Pre-confirmation viewer lookup failure → still prompts for title/description, names comments as skipped (viewer unavailable). +- Aggregates =pushed/conflict/failed/skipped/unchanged= counts; a conflict on ticket 2 doesn't stop tickets 1 and 3. +- Snapshot: edits after the initial scan aren't included until a later save. +- Malformed drawer on one issue → counted =failed/skipped=, batch continues. + +*Keymap / menu / docs* +- =pearl-prefix-map= is defined; =e e= reaches =save-issue=; =e a= reaches =save-all=; the map is *not* globally bound at load. +- Transient keeps the retargeted suffixes (locks the menu group). +- README examples use the final command names and keep the lossiness warnings near the save docs. + +* Review Dispositions + +*Round 1 (Codex, 2026-05-24).* Everything was accepted and woven into the body, *except* the two below, which were modified. + +- *HP6 / Open question 4 — keymap install helper (modified).* Accepted the core: opt-in, no global bind at load, define =pearl-prefix-map=, document a =global-set-key= / =:bind-keymap= snippet. Declined the optional imperative =pearl-install-prefix-key= helper the review floated — =define-prefix-command= plus a documented binding is the idiomatic Emacs path, and shipping an installer invents API for something the user does in one line. The snippet is the API. +- *MP3 / Open question 3 — =cancelled= as a top-level status (modified).* The review's outcome enum listed =cancelled= alongside =conflict=. Folded =cancelled= into =conflict= with =:reason cancelled= (and smerge abort as =:reason aborted=): cancelling or aborting a conflict *leaves the field in conflict* — it didn't resolve to anything — so a separate top-level status double-counts the same end state. =:status= stays the smaller set =pushed|unchanged|conflict|resolved-remote|skipped|failed=; the cancel/abort detail lives in =:reason=, which summaries can still surface. + +Everything else — HP1 (Org-hash-first dirty detection), HP2 (structured outcome contract), HP3 (sequential continuation semantics), HP4 (viewer-once-per-batch), HP5 (save-all confirm + dry scan), MP1 (resolve open questions into decisions), MP2 (snapshot rule), MP4 (compose stays direct-push), MP5 (exact transient layout), MP6 (README lossiness warnings), and the architecture / robustness / test-strategy observations — accepted as written. + +*Round 2 (Codex, 2026-05-24).* All accepted as written; no modifications. HP1 (two-phase comment ownership) corrected a genuine contradiction this author introduced in round 1 — the "no remote calls" save-all scan couldn't count read-only comment skips without the viewer id — and pinned the comment hash to =org->md= against =LINEAR-COMMENT-SHA256= (verified against =pearl--format-comment= and =pearl-edit-current-comment=). Folded in as the two-phase rule with a permitted pre-confirmation read-only viewer lookup. MP1 (multi-account not a prerequisite) and MP2 (stale =todo.org= wording) accepted; the stale task decisions block was trimmed to point at this spec rather than carry superseded pre-spec decisions. + +* vNext / Out of Scope + +- Review-changes-before-save diff buffer (=git add -p= style) across the file before pushing. +- A save-all dry-run command that reports dirty fields without prompting to push. +- Parallel save execution with conflicts queued and presented at the end, once the sequential engine is stable. +- Auto-save on buffer save (its own task; depends on this model's no-op/conflict detection). +- Undo/rollback of a just-pushed change. diff --git a/docs/specs/ticket-save-model-v2-spec.org b/docs/specs/ticket-save-model-v2-spec.org new file mode 100644 index 0000000..c16a0d8 --- /dev/null +++ b/docs/specs/ticket-save-model-v2-spec.org @@ -0,0 +1,227 @@ +#+TITLE: pearl — Ticket Save Model v2: Structured Fields on the Save Engine +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-25 +#+STARTUP: showall + +* Status + +*READY — Craig's go 2026-05-25. Rounds 1-2 review incorporated (Codex, 2026-05-25); implementation-ready.* Round 1's five blocking + four medium findings and round 2's two blocking + two medium findings are dispositioned and folded into the body; see "Review dispositions". Round 2's two blockers — the after-save full-file sync was a second undefined immediate push, and the state dirty rule needs faithful keyword rendering — are resolved: v2 removes the whole immediate-state-push org-sync (both hooks + the push functions), and keyword-derivation is now a stated prerequisite for the state keyword-cycle path (consistent with the agreed foundations-first order), with picker-id-only state as the no-companion fallback. + +Extends [[file:ticket-save-model-spec.org][ticket-save-model-spec.org]] (v1, shipped). v1 unified the three free-text fields (title, description, comments) onto a save engine but left the four structured fields (priority, state, assignee, labels) as immediate-push commands (v1 decision 8). This spec folds the structured fields onto the same engine and removes immediate push, so the package has exactly one write path. It supersedes the "field setters stay immediate" half of v1 decision 8; the "compose buffers stay direct-push" half stands (v2 does not touch the compose buffers). + +Companion to [[file:labels-as-org-tags-spec.org][labels-as-org-tags-spec.org]] (renders labels as org tags — *display-only*, not an editing path), [[file:todo-keywords-from-workflow-states-spec.org][todo-keywords-from-workflow-states-spec.org]] (makes keyword cycling reach every state), and [[file:issue-conflict-handling-spec.org][issue-conflict-handling-spec.org]] (the text conflict gate; v2 adds an atomic sibling). + +* Problem + +The package has two editing paradigms, and the seam between them is what makes it feel incoherent: + +1. *Free-text fields* (title, description, comments) — you edit the buffer, then run a save (=pearl-save-issue= / =pearl-save-all=). The engine diffs against a stored baseline and pushes only what changed. +2. *Structured fields* (priority, state, assignee, labels) — a completing-read command pushes the moment you pick a value. No buffer edit, no diff, no save. + +The save engine only knows about kind 1. Kind 2 never entered it. That split is the whole "schizophrenic" feeling: two mental models for "change a ticket," two trigger moments, and a save command that silently doesn't cover four of the fields it appears to. + +The goal: one model. You edit the buffer — including the structured fields, each through whatever affordance fits — and a save reconciles everything against the remote and pushes the diff. Immediate push goes away. + +* The reframe + +The structured setters aren't a different save model. They're a different /push trigger/ bolted onto the same data. Every field already has a buffer representation: + +| Field | Buffer representation | +|-------------+--------------------------------------------------------| +| title | heading text | +| description | subtree body | +| state | TODO keyword (live id) + =LINEAR-STATE-ID-SYNCED= base | +| priority | =[#A]/[#B]/[#C]/[#D]= cookie + =LINEAR-PRIORITY= base | +| assignee | =LINEAR-ASSIGNEE-ID= live + =-SYNCED= base; name shown | +| labels | =LINEAR-LABEL-IDS= live + =-SYNCED= base; tags shown | +| comments | child subtrees | + +So the buffer is the source of truth for all of them. The only difference is /when/ the push fires. Unify the trigger and the split is gone. + +** The editing affordance: org-native where the construct is editable and faithful, a picker otherwise + +You don't hand-type a constrained field; you pick it, and the pick edits the buffer instead of pushing. Each existing setter already has two halves: a completing-read picker (keep it) and an immediate push (remove it). The picker becomes a buffer-editor — it writes the field's representation and stops. + +A field is edited org-natively only where org has a faithful, editable construct for it. Otherwise a pearl picker is the editor: + +- *Priority* — org-native. Five Linear priorities map cleanly to None → no cookie and Urgent/High/Medium/Low → =[#A]/[#B]/[#C]/[#D]= (the package already binds =org-priority-lowest= to =?D=, so =[#D]= works today, =pearl.el:2668=). Edited via org's keys (=C-c ,= / =S-up= / =S-down=). =pearl-set-priority= is deleted. +- *State* — keyword cycling for the common case (=C-c C-t=), plus a picker (=pearl-edit-state=) for the lossy edge. The keyword reaches every state once [[file:todo-keywords-from-workflow-states-spec.org][keyword derivation]] lands, but same-team slug collisions (=Dev Review= / =Dev-Review= → =DEV-REVIEW=) leave a residual case cycling can't express by keyword alone; the picker reaches the exact state id there. +- *Labels* — picker (=pearl-edit-labels=), id-based. Org tags *display* the labels (companion spec) but are *render-only* — editing a tag does not edit the label. Bidirectional tag editing is a separate, larger design (vNext); v2 edits labels through the picker like a relation field. +- *Assignee, project, cycle, estimate* — picker (=pearl-edit-assignee=, etc.); no org-native construct. The picker writes the id and the display name. + +Free-text fields (title, description, comments) stay fully hand-editable. Everything constrained moves through a picker — org's or pearl's — never freehand. + +* Resolved forks + +1. *Authority over id-bearing fields (assignee, project, labels).* Picker-authoritative. The picker writes the live id (and the display name/tags for show). Dirty detection compares the *id* against its synced baseline, never the displayed text. A hand-edit to a display name or tag is *cosmetic and ignored* — refresh and save-success rewrite the display from the authoritative id, so the buffer reconverges. No =name-id-mismatch= reporting (see Review disposition HP5): a field whose id matches baseline is simply not dirty, so no saver runs to report anything. +2. *Conflict policy for structured fields.* An atomic enum/relation can't be text-merged, so v2 adds a sibling to the text conflict gate — =pearl--resolve-atomic-conflict= — with use-local / use-remote and *no* smerge branch. Conflict means baseline ≠ remote /and/ baseline ≠ live /and/ remote ≠ live; the prompt offers keep-mine (push) or take-theirs (adopt remote, advance baseline). It does not write SHA hashes or touch the kill ring (the text helper's machinery is wrong for ids). +3. *Immediate push — every path of it.* Removed. Two state-pushing paths exist today, both wired by =pearl-enable-org-sync=: the =org-after-todo-state-change-hook= (push on keyword cycle) and the =after-save-hook= (=pearl-org-hook-function= → =pearl-sync-org-to-linear=, a whole-file state push on buffer save). v2 unwires both and removes the push machinery (see "Org-sync removal" below). The setters also stop calling =pearl--push-issue-field=. All pushes go through the save engine. Reverses the setters half of v1 decision 8. Auto-pushing on buffer save is deferred to the separate "Automatic sync on save" task, which would call the save engine, not the old whole-file pusher. +4. *Save scope.* Keep both =pearl-save-issue= (this ticket) and =pearl-save-all= (whole buffer). No change to v1's split. + +* Current state + +- The four setters (=pearl-set-priority= / =-state= / =-assignee= / =-labels=) prompt via completing-read, push immediately via =pearl--push-issue-field= (=pearl.el:2704=, =:2738=, =:2775=, =:2805=), then write the org representation. The representation-writing half exists; only the push half is removed. +- The save engine (=pearl--issue-dirty-fields= at =pearl.el:3246=, =pearl--save-field-thunks= at =:3510=, =pearl--run-save-queue=, the save-outcome contract) handles title / description / comments only. +- =pearl-enable-org-sync= adds =pearl-sync-org-to-linear= to =org-after-todo-state-change-hook= (=pearl.el:4069=) — keyword cycling is an immediate push today. +- Issue queries already fetch the remote structured values for conflict checks: =priority=, =state { id name type }=, =assignee { id name }=, =labels { nodes { id name } }= (=pearl.el:888=, =:908=). =pearl--update-issue-async= already builds =IssueUpdateInput= for =priority=, =stateId=, =assigneeId=, =labelIds= (=pearl.el:2640=). +- The renderer writes display values: =LINEAR-STATE-ID= / =LINEAR-STATE-NAME=, the cookie, =LINEAR-ASSIGNEE-ID= / =-NAME=, =LINEAR-LABELS= (names only, =pearl.el:1914=). No separate synced baseline exists for any structured field. + +* Proposed design + +** Live value and synced baseline per field + +Reconcile-on-save needs the live value (what the affordance/picker set) separate from the baseline (last synced from Linear). Dirty = live ≠ baseline; a successful push advances the baseline. Render writes live = baseline at fetch (nothing dirty on a fresh fetch); the merge refresh advances both for any subtree it re-renders and leaves a retained dirty subtree's live alone. + +- *Priority* — live = the priority number of the current cookie (the full mapping is in "Priority cookie mapping" below: None/Urgent/High/Medium/Low ↔ 0/1/2/3/4, cookie none/A/B/C/D); baseline = =LINEAR-PRIORITY=. The cookie is the live artifact; no extra live property. +- *State* — baseline = =LINEAR-STATE-ID-SYNCED= and =LINEAR-STATE-NAME= (the synced name, kept for slug comparison). The *picker path always works*: =pearl-edit-state= writes an explicit live =LINEAR-STATE-ID=, and dirty = that id ≠ the synced baseline — no keyword faithfulness needed. The *keyword-cycle path* (slug of the current keyword ≠ slug of =LINEAR-STATE-NAME= ⇒ cycled, resolve the id in the saver) is sound *only when keyword derivation is present*, because it assumes a fresh fetch renders each state as its slug keyword. Without that companion, the static mapping renders unmapped states as the =TODO= fallback, so slug(keyword) ≠ slug(name) on a clean fetch and the issue would look falsely dirty. So the keyword-cycle dirty path is *gated on the keyword-derivation invariant* (the derived =#+TODO:= header + faithful slug rendering); until that lands, state is picker-id-only and keyword changes are not scanned as state edits. See Dependencies. +- *Labels* — live = =LINEAR-LABEL-IDS= (the picker writes the id set); baseline = =LINEAR-LABEL-IDS-SYNCED=. =LINEAR-LABELS= stays display-only (names/tags). Set comparison is order-insensitive and duplicate-free, so reordered labels don't read dirty. +- *Assignee / project / etc.* — live = =LINEAR-ASSIGNEE-ID= (picker-written); baseline = =LINEAR-ASSIGNEE-ID-SYNCED=. The display name (=LINEAR-ASSIGNEE-NAME=) is shown but never the dirty basis. + +*Priority cookie mapping (settled).* None → no cookie; Urgent → =[#A]=; High → =[#B]=; Medium → =[#C]=; Low → =[#D]=. =[#D]= already works via the existing =org-priority-lowest= =?D= binding. =LINEAR-PRIORITY= holds the numeric baseline; dirty = cookie's priority number ≠ baseline. No lossy collapse — the earlier three-cookie idea was wrong and is dropped. + +** Dirty detection extended + +=pearl--issue-dirty-fields= gains =:state=, =:priority=, =:labels=, =:assignee= beside =:title= / =:description= / =:comment-candidates=. Every structured check is id/scalar-only and network-free: priority compares cookie# vs =LINEAR-PRIORITY=; labels compare id sets (order-insensitive) vs =LINEAR-LABEL-IDS-SYNCED=; assignee compares =LINEAR-ASSIGNEE-ID= vs =-SYNCED=; state compares the explicit picker id vs =LINEAR-STATE-ID-SYNCED= always, and the keyword slug vs =LINEAR-STATE-NAME= only when keyword derivation is present (otherwise the keyword is not scanned, per the gating above). No name resolution and no remote read at scan time. The id-resolution and remote read for state happen inside the per-field saver, after the work list is known. + +** Per-field savers + the atomic conflict gate + +Each structured field gets a per-field async saver matching the v1 contract (marker + callback → one save-outcome plist). To avoid bending the text-oriented saver around enum/id fields, structured fields run through a parallel =pearl--run-atomic-field-save= with the same outcome plist. Each saver: + +1. Reads live + baseline. If equal, =unchanged= (re-checked at push). +2. Fetches the remote value for that field (the one remote read, here not at scan). +3. Atomic three-way: remote = live → =unchanged=, advance baseline. remote = baseline (remote didn't move) → clean push → =pushed=. remote ≠ baseline and remote ≠ live → =conflict= → =pearl--resolve-atomic-conflict= (use-local pushes; use-remote adopts remote, advances baseline, =resolved-remote=; no smerge). +4. On a successful push, advance the baseline and rewrite the display fields (assignee name, label tags). The mutation result from =pearl--update-issue-async= returns only =id updatedAt=, not the new relation display, so the display source is: use-local → the picker's cached live display (the name/tags it selected); use-remote → the fetched remote display already in hand from the conflict fetch. Neither path needs to expand the mutation selection set. + +=pearl--resolve-atomic-conflict= contract: prompt label, use-local callback, use-remote callback, no rewrite/smerge option, callback maps to =pushed= / =resolved-remote= / =conflict= (=:reason cancelled=). + +The save-outcome =:field= enum grows =state | priority | labels | assignee=. =:reason= keeps the existing set and adds =missing-property= (migration, below). =name-id-mismatch= is *not* added — display edits are ignored, not reported. + +** Setter conversion, renames, deletions + +Pre-release, so old command names are removed outright (no aliases). Each rename touches the command def, =pearl-menu=, =pearl-prefix-map=, README suggested keys, tests, and docstrings. + +- =pearl-set-priority= → *deleted*. Priority is org-native via the cookie; the engine reconciles =LINEAR-PRIORITY=. +- =pearl-set-labels= → renamed =pearl-edit-labels=, converted to a buffer-editor: the picker writes =LINEAR-LABEL-IDS= (live) + =LINEAR-LABELS= (display), marks dirty, no push. Kept (tags are render-only, so the picker is the label editor). +- =pearl-set-state= → renamed =pearl-edit-state=, buffer-editor: writes keyword + =LINEAR-STATE-NAME= + explicit live =LINEAR-STATE-ID=, no push. Kept for the lossy edge. +- =pearl-set-assignee= → renamed =pearl-edit-assignee=, buffer-editor: writes =LINEAR-ASSIGNEE-ID= (live) + =-NAME= (display), no push. +- =pearl-compose-current-description= → renamed =pearl-edit-description= (the rename task; its compose behavior is unchanged — v2 doesn't touch compose buffers). + +** Org-sync removal + +The =pearl-enable-org-sync= mode exists only to push state immediately, which v2 forbids. It wires two state-pushing hooks, and both go: + +- =org-after-todo-state-change-hook= → push on keyword cycle. Unwired. Cycling a keyword now marks dirty, reconciled at save. +- =after-save-hook= → =pearl-org-hook-function= → =pearl-sync-org-to-linear=, a whole-file state push on every buffer save. Unwired. Buffer save no longer pushes anything; only =pearl-save-issue= / =pearl-save-all= push. + +With both hooks gone, the push machinery has no caller and is removed: =pearl-sync-org-to-linear=, =pearl-sync-current-heading-to-linear=, =pearl-org-hook-function=, and =pearl-enable-org-sync= / =pearl-disable-org-sync=. This fully subsumes the "make the org-sync commands private" task — there's nothing left to privatize. The keyword→state-id *resolution* that keyword derivation builds is retained, but it's consumed by the state per-field saver at save time, not by an immediate push on cycle (see the cross-spec note in Dependencies). Re-introducing an auto-push on buffer save is the separate "Automatic sync on save" task, which would call the save engine. + +** Legacy-file migration + +Existing rendered files lack =LINEAR-PRIORITY=, =LINEAR-LABEL-IDS(-SYNCED)=, =LINEAR-STATE-ID-SYNCED=, =LINEAR-ASSIGNEE-ID-SYNCED=. A structured saver that finds a missing baseline returns =skipped=, =:reason missing-property=, with a message to refresh the view first (a refresh re-renders and writes all baselines). The save does not guess a baseline or push blindly. Title/description/comments on the same issue still save normally. + +** =save-all= prompt and summary + +The confirm prompt and final summary count structured fields alongside the free-text ones, e.g. =Save 6 fields across 2 issues? (1 title, 1 description, 2 states, 1 priority, 1 assignee)=. Declining performs no structured-field fetch or mutation. The summary aggregates =pushed / unchanged / conflict / failed / skipped= with reasons (including =missing-property= skips). + +** Affordance summary after v2 (+ companions) + +| Field | Edit affordance | Dirty basis | +|-------------+------------------------------------------------+--------------------------------------| +| title | hand-edit heading | =LINEAR-TITLE-SHA256= | +| description | hand-edit body | =LINEAR-DESC-ORG-SHA256= | +| comments | hand-edit / compose | =LINEAR-COMMENT-SHA256= | +| state | =C-c C-t= cycle; =pearl-edit-state= for the edge | keyword/picker id vs =-STATE-ID-SYNCED= | +| priority | =C-c ,= / =S-up/down= | cookie# vs =LINEAR-PRIORITY= | +| labels | =pearl-edit-labels= picker (tags display only) | id set vs =LINEAR-LABEL-IDS-SYNCED= | +| assignee | =pearl-edit-assignee= picker | =LINEAR-ASSIGNEE-ID= vs =-SYNCED= | + +* Agreed decisions + +1. One write path: every push goes through the save engine. The setters lose their push; *both* org-sync state-push paths (=org-after-todo-state-change-hook= and the =after-save-hook= whole-file sync) are unwired, and the push machinery (=pearl-sync-org-to-linear=, =-current-heading-to-linear=, =pearl-org-hook-function=, =pearl-enable-/disable-org-sync=) is removed. (Supersedes the setters half of v1 decision 8; subsumes the "make org-sync private" task.) +2. Org-native editing only where the construct is faithful and editable: priority (cookie A/B/C/D, setter deleted) and state's common case (keyword cycling — gated on keyword derivation, decision 8). State keeps a picker for the slug-collision edge and as the always-available path. Labels keep a picker (tags are render-only). Assignee/project keep pickers (no native construct). +3. Id-bearing fields are picker-authoritative; the id is the dirty basis; display-name/tag edits are cosmetic and ignored (refresh/save-success rewrite them). No =name-id-mismatch=. +4. Structured-field conflicts use a new =pearl--resolve-atomic-conflict= (use-local / use-remote, no smerge), separate from the text gate. +5. Each structured field has a synced baseline (=LINEAR-PRIORITY=, =LINEAR-LABEL-IDS-SYNCED=, =LINEAR-STATE-ID-SYNCED=, =LINEAR-ASSIGNEE-ID-SYNCED=); dirty = live ≠ baseline (id/scalar, network-free); a push advances the baseline. +6. Both =save-issue= and =save-all= stay; structured fields fold into the same queue and outcome contract; =save-all= counts them in its prompt. +7. Missing baselines on legacy files → =skipped/missing-property= with a refresh message; no blind push. +8. Keyword derivation is a prerequisite for the state keyword-cycle dirty path (lands before the v2 state work, per the foundations-first order). Picker-id state needs no companion. The keyword-derivation "push on cycle" is replaced by v2's reconcile-at-save; its keyword→id map is consumed by the state saver. +9. Save-success display rewrite source: use-local → the picker's cached display; use-remote → the display from the conflict fetch. The mutation result (=id updatedAt= only) is not relied on for relation display. + +* Dependencies / sequencing + +Most of v2 stands alone on the picker/id substrate; the state *keyword-cycle* path is the one piece with a hard prerequisite. Ordered foundations-first (as agreed): + +1. [[file:todo-keywords-from-workflow-states-spec.org][keyword derivation]] (READY) — *prerequisite for state keyword-cycle editing.* It makes a fresh fetch render every state as its faithful slug keyword and declares them in =#+TODO:=. Without it, the static-mapping fallback renders unmapped states as =TODO=, so the keyword-vs-name dirty rule would false-positive on a clean fetch (round-2 HP2). It lands before the v2 state work. *Cross-spec reconciliation:* the keyword-derivation spec was written assuming a cycle pushes state immediately (via =pearl--process-heading-at-point=). Under v2 that immediate push is gone — a cycle marks dirty and reconciles at save. So keyword derivation contributes the faithful keyword rendering + the keyword→state-id reverse map; v2's state saver consumes that map at save time. The "push on cycle" half of keyword derivation is replaced by v2's reconcile-at-save, not implemented twice. +2. *This spec's core* — fold structured fields into the engine, convert/rename/delete setters, remove the org-sync push paths. Priority is cookie-native; labels and assignee are picker/id-based; state is picker-id-based always, plus keyword-cycle editing once (1) is in place. The priority/labels/assignee/engine/removal work has no companion dependency and can land independently of (1). +3. [[file:labels-as-org-tags-spec.org][labels-as-org-tags]] (READY) — *display* upgrade only (tags reflect labels). Not an editing path; label editing stays on the picker until a separate bidirectional-tag spec (vNext). + +* Files touched + +- =pearl.el=: =pearl--issue-dirty-fields= (structured scanners); per-field atomic savers + =pearl--run-atomic-field-save=; =pearl--resolve-atomic-conflict=; the save-outcome =:field= / =:reason= enums; render + merge writing the new =-SYNCED= baselines; the setters converted/renamed/deleted; both org-sync hooks unwired and the push machinery removed (=pearl-sync-org-to-linear=, =-current-heading-to-linear=, =pearl-org-hook-function=, =pearl-enable-/disable-org-sync=); =save-all= prompt/summary counts. +- =tests/=: =test-pearl-sync-hooks.el= updated — the removed sync functions/hooks; the new "buffer save pushes nothing" assertions. +- =docs/=: this spec. +- =README.org=: the unified edit model — which affordance edits which field, that every change reconciles at save, and the renamed commands. + +* Test plan + +New cases in =tests/test-pearl-save.el= (keymap/menu files for the renames): + +*Dirty scan (structured, network-free)* +- Clean fetched issue → nothing dirty (live = baseline for all). +- Cookie changed vs =LINEAR-PRIORITY= → priority dirty; Low (=[#D]=) round-trips and is distinct from None. +- State picker chose any id ≠ =LINEAR-STATE-ID-SYNCED= → dirty (no keyword faithfulness needed); collision-second id pushes that exact id. +- With keyword derivation present: a fresh fetch of a non-static team state ("Dev Review") renders its slug keyword and is *not* dirty; cycling the keyword so its slug ≠ =LINEAR-STATE-NAME= → state dirty. +- Without keyword derivation: keyword changes are not scanned as state edits (state is picker-id-only); a fresh fetch of a state that falls back to =TODO= is *not* dirty. +- Label id set ≠ =LINEAR-LABEL-IDS-SYNCED= → dirty; reordered labels → not dirty (order-insensitive). +- Assignee picker id ≠ =-SYNCED= → dirty; hand-edited display name with matching id → not dirty (cosmetic). + +*Per-field save (HTTP stubbed)* +- Clean field → no fetch/update. +- Remote unmoved → clean push, baseline advances. +- Remote moved to the same value as live → =unchanged=, baseline advances, no push. +- Remote moved differently → =conflict= via the atomic gate; use-local pushes; use-remote adopts remote and advances baseline; no smerge offered. +- State collision: picker chose the second id; save pushes that exact id, not the keyword's first-by-position. +- State keyword edit: saver resolves the keyword to its id, pushes, advances baseline. +- Push failure → live + baseline untouched, =failed/push-failed=. +- Missing baseline (legacy file) → =skipped/missing-property=, no push; title/description on the same issue still save. + +*Integration* +- =save-issue= dirty in title + state + assignee pushes all three through the queue, one summary. +- =save-all= prompt names each structured-field count; declining does no structured fetch or mutation. + +*Org-sync removal (HP1 round 2)* +- =pearl-enable-org-sync= no longer exists / installs no state-pushing hook; the old sync functions are gone. +- Cycling a TODO keyword pushes nothing; the change appears only at the next save. +- Saving the org buffer pushes nothing (the old whole-file =after-save= state sync is removed); only =pearl-save-issue= / =pearl-save-all= push. + +*Renames / deletions* +- =pearl-edit-state= / =pearl-edit-assignee= / =pearl-edit-labels= / =pearl-edit-description= exist as buffer-editors (no network on selection); =pearl-set-*= names are gone; =pearl-set-priority= has no replacement command. + +* Review dispositions + +*Round 1 (Codex, 2026-05-25). Verdict: Not ready.* All findings dispositioned; the two open forks resolved by Craig. + +- *HP1 (state live/baseline collapse) — accepted.* Adopted the separate-slot model: baseline =LINEAR-STATE-ID-SYNCED= (+ =LINEAR-STATE-NAME= for slug compare); live = explicit picker =LINEAR-STATE-ID= when set, else the keyword (slug-compared at scan, id resolved in the saver). Render/picker/scan/save-success/test sections rewritten to match. +- *HP2 (labels vs render-only companion) — accepted; fork resolved.* v2 does *not* supersede labels-as-tags. Labels stay picker/id-based like a relation; tags remain render-only display. Bidirectional tag editing is vNext. +- *HP3 (interim label live id) — accepted.* The picker writes =LINEAR-LABEL-IDS= (live) + =LINEAR-LABEL-IDS-SYNCED= (baseline); =LINEAR-LABELS= stays display-only. Dirty is an order-insensitive id-set compare, network-free. +- *HP4 (priority 1:1 vs lossy) — accepted; factual correction.* The code already supports =[#D]= (=org-priority-lowest= =?D=). Mapping settled: None→none, Urgent/High/Medium/Low→A/B/C/D. The lossy three-cookie idea is dropped. +- *HP5 (name-id-mismatch contradiction) — accepted; fork resolved.* Display-name/tag edits are cosmetic and ignored; refresh/save-success rewrite them from the id. =name-id-mismatch= dropped. A non-dirty field runs no saver, so there was nothing coherent to report. +- *MP1 (atomic conflict helper) — accepted.* Added =pearl--resolve-atomic-conflict= with its own contract; the text gate is left for free-text fields. +- *MP2 (save-all counts) — accepted.* Prompt/summary count each structured field; example wording added. +- *MP3 (rename surface) — accepted.* Old command names removed (pre-release), README/menu/keymap/tests/docstrings updated. +- *MP4 (legacy migration) — accepted.* Missing baseline → =skipped/missing-property= with a refresh message; no blind push. +- Test-strategy additions (collision push, keyword-edit, order-insensitive labels, Low/None, missing baseline, save-all counts) folded into the test plan. + +*Round 2 (Codex, 2026-05-25). Verdict: Not ready.* Both blocking and both medium findings accepted. + +- *HP1 (after-save hook still pushes) — accepted.* I'd only named the =org-after-todo-state-change= hook; the =after-save-hook= whole-file sync is a second immediate state push. v2 now unwires both hooks and removes the whole push machinery (new "Org-sync removal" section), subsuming the "make org-sync private" task. Auto-push-on-save is deferred to its own task, built on the engine. +- *HP2 (state dirty depends on keyword derivation) — accepted; sequencing corrected.* The "no dependency" claim was wrong for the keyword-cycle path: the static-mapping =TODO= fallback would make a clean fetch look dirty. Keyword derivation is now a stated prerequisite for keyword-cycle state editing (consistent with the agreed foundations-first order), with picker-id-only state as the no-companion fallback. The cross-spec tension (keyword derivation assumed push-on-cycle; v2 reconciles at save) is reconciled in Dependencies. +- *MP1 (priority numeric shorthand) — accepted.* Fixed the garbled "0/2/3/4 plus Urgent" to the correct None/Urgent/High/Medium/Low ↔ 0/1/2/3/4. +- *MP2 (display-rewrite source) — accepted.* Named the source: use-local → picker's cached display; use-remote → the conflict-fetch display. The mutation result (=id updatedAt=) isn't relied on for relation display. + +* Out of scope / vNext + +- Bidirectional org-tag label editing (a real tag→label save model: unknown-tag handling, label creation/refusal, collision, manual-deletion semantics). v2 keeps labels on the picker. +- Auto-save on buffer save (its own task, builds on this no-op/conflict detection). +- Project/cycle/estimate pickers — same picker pattern, added after assignee proves the shape. +- Richer priority UI if org cookies prove insufficient long-term. diff --git a/docs/specs/todo-keywords-from-workflow-states-spec.org b/docs/specs/todo-keywords-from-workflow-states-spec.org new file mode 100644 index 0000000..81f296c --- /dev/null +++ b/docs/specs/todo-keywords-from-workflow-states-spec.org @@ -0,0 +1,217 @@ +#+TITLE: pearl — Derive Org TODO Keywords from Linear Workflow States Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +READY (derivation half) — implementation-ready, Craig's go 2026-05-25. Reviews incorporated (2026-05-24, rounds 1–4; 2026-05-25, round 5). Implements the =todo.org= task "Derive the org TODO keywords from the Linear workflow states". =WorkflowState.position= was verified live (2026-05-25) — it returns a float (e.g. 947.14), so per-team =position= ordering works and the no-position fallback isn't needed. + +*Superseded in part by [[file:ticket-save-model-v2-spec.org][ticket-save-model-v2-spec.org]] (2026-05-25).* This spec predates save-model-v2, which removed the org-sync push machinery the "Sync-back: cycled keyword → Linear state" section below depends on (=pearl--process-heading-at-point=, =pearl--update-issue-state-async=, =pearl-sync-org-to-linear= are all gone). That section is *obsolete* — v2 reconciles state at save through =pearl--save-state-field=, and the cycled-keyword → state-id resolution is the separate "State keyword-cycle dirty detection" task (c8), which consumes the faithful keyword rendering this spec produces. So this build covers the *derivation / rendering half only*: the derived =#+TODO:= header, =slugify=-based per-state keywords, the =:LINEAR-STATE-TYPE:= drawer, and the merge-refresh header update. The slug-match-against-team-states logic in the sync-back section moves to c8 as a save-time resolution (no =process-heading=, no immediate push). + +*c8 depends on the faithful-keyword invariant this half establishes:* every fresh render sets the heading keyword to =slugify(state-name)=. c8's dirty scan reads a keyword that differs from =slugify(LINEAR-STATE-NAME)= as a user cycle and resolves it back to a state id at save. The one stale case is a file rendered in the narrow window between save-model-v2 and this derivation build, holding a non-standard state (keyword =TODO=, name "Dev Review"): c8 reads it as cycled and would push the =TODO= state over it. A refresh re-renders faithfully and closes the gap; acceptable for pre-1.0 dogfooding. + +* Problem + +The generated file's =#+TODO:= line is fixed — either the hardcoded =TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE= or a copy of the user's global =org-todo-keywords= — and =pearl-state-to-todo-mapping= is a static six-entry default. Neither reflects a team's real Linear states (Dev Review, PM Acceptance, Icebox, Grooming, …). + +Org only cycles a heading to a keyword listed in the file's =#+TODO:=. So you cannot move a ticket to "Dev Review" by cycling its TODO keyword: the keyword isn't in the line, and there's no mapping entry for it. =pearl-set-state= already reaches any state (id-based, header-independent), but the keyword-cycle path — the natural org gesture — is stuck on the hardcoded six. + +The =#+TODO:= line therefore becomes *generated infrastructure* for the sync-back write path. If it is missing a rendered keyword, stale after a merge refresh, or ambiguous after slugification, org-native state changes silently stop being a trustworthy write path. The header-and-reverse-lookup contract below is written to that bar. + +* Current state + +- =pearl--build-org-content= writes =#+TODO:= from =org-todo-keywords= (or the hardcoded fallback). It's a pure function. +- =pearl--map-linear-state-to-org= renders an issue's keyword by =assoc= on =pearl-state-to-todo-mapping= (fallback =TODO=). +- =pearl--map-org-state-to-linear= resolves a cycled keyword back to a Linear state *name* by =rassoc= on the same mapping; =pearl--process-heading-at-point= then calls =pearl--update-issue-state-async= with that name + team id. +- =pearl--get-todo-states-pattern= builds the full-file scan regex from the mapping's keywords (cached in =pearl-todo-states-pattern=). +- =pearl--team-states= is a *synchronous, cached* accessor (blocks via =pearl--wait-for= on first fetch, then serves from =pearl--cache-states=). =pearl-set-state= already calls it synchronously from command context. +- The team-states GraphQL query fetches =id name color= only. Issue queries already fetch state =type= (but not workflow-state =position=). +- The same-source refresh path (=pearl--merge-query-result= → =pearl--merge-issues-into-buffer=) updates issue subtrees in place and only rewrites the run-at / count / truncation header lines via =pearl--update-source-header=. It does *not* rebuild the file, so it does not touch =#+TODO:= today. + +* Decisions + +Settled inputs for v1 (A1 / B2 / C-yes agreed with Craig 2026-05-24; the remainder resolved from review): + +- *A1 — union.* A file may hold issues from several teams. Build one =#+TODO:= line from the union of all involved teams' states, de-duplicated by slug. +- *B2 — derived replaces.* The keyword is always the slugified Linear state name. =pearl-state-to-todo-mapping= is *removed*, not layered. One source of truth (Linear), an honest header, a deterministic round-trip (keyword = =slugify(name)= everywhere, no stored reverse map). +- *C-yes — defer cross-team slug collisions.* Documented known limitation (see out of scope). +- *Done-side types.* =completed=, =canceled=, *and* =duplicate= render after the =|=; =triage=, =backlog=, =unstarted=, =started= before it. Split by =type=, never by name. +- *Header coverage guarantee.* Every keyword visible on a heading in the buffer must be declared in =#+TODO:= — that header powers org cycling and sync-back. The header is the slug-union of (a) every visible issue heading's state and (b) every team's full state set that was fetched successfully. (a) guarantees coverage even when a team's state fetch fails; (b) makes absent states cyclable when available. A failed team's only degradation is that you can't cycle to a state none of its visible headings is in. The hardcoded line is used only when there are no states at all. The "visible headings" set differs by path: a full rebuild reads the normalized issue list; a merge refresh reads the *final buffer* (so retained/skipped dirty subtrees are covered — see below). +- *Merge coverage via final-buffer scan.* For merge refresh the header is rebuilt from the final displayed buffer (scan every Linear issue heading's current TODO keyword) unioned with the fetched team states — not from the fetched issue list alone, which omits retained dirty subtrees. The buffer scan subsumes the fetched issues (they're in the buffer) and directly validates the invariant users see. +- *Issue-own / position-less ordering.* Full workflow states (from team-states) order by =position=; states drawn from headings or the issue list carry no workflow =position=, so they append in first-seen order within their active/done partition, de-duped by slug. +- *Slugify is Unicode-aware and locale-independent.* +- *Same-team slug collision.* The pure gather/derive layer returns collision metadata (the colliding slug + states); the render/sync layer logs it. The header de-dups; sync-back resolves the keyword to the *first state by =position=*. Returning the metadata keeps the lossy transform testable without capturing =message=. +- *Unknown keyword behavior splits by path.* Interactive current-heading sync (an =org-todo= cycle) whose keyword resolves to no team state raises a =user-error= naming the keyword + team. The full-file save scan reports and *skips* the unknown heading and continues to the rest — one stale heading must not abort syncing the others or surface as an after-save-hook error. +- *Store the state type in the drawer.* Render a =:LINEAR-STATE-TYPE:= drawer field (the issue query already fetches state =type=). The active/done side of a keyword is a function of =type=, and the merge final-buffer scan recovers a retained heading's keyword but not its type from name/id alone — so the type must travel with the heading. Classification on the merge scan: by the heading's own =:LINEAR-STATE-TYPE:= when present (deterministic); else (a legacy heading written before this field) preserve the keyword's current side from the buffer's parsed TODO config — done side if the keyword is in =org-done-keywords=, active otherwise; else (no parseable =#+TODO:= / keyword unknown to Org) default to the active side and log a warning naming the keyword and issue. Retained headings thus keep their old Org done/active semantics until a clean refresh re-derives them from Linear. + +* Proposed design + +** Slugify: state name → org keyword + +A new pure helper =pearl--state-name-to-keyword=: + +- Upcase (locale-independent — Emacs =upcase=, no locale-sensitive casing). +- Replace each run of characters *not* matched by =[[:alnum:]]= with a single hyphen. =[[:alnum:]]= is Unicode-aware in Emacs, so accented and non-Latin letters are preserved rather than stripped. +- Trim leading/trailing hyphens. +- If the result is empty (an all-punctuation/symbol name), fall back to =TODO=. + +Expected outputs: + +| Input | Output | +|--------------------+-------------------| +| =Dev Review= | =DEV-REVIEW= | +| =In Progress= | =IN-PROGRESS= | +| =Todo= | =TODO= | +| =PM Acceptance= | =PM-ACCEPTANCE= | +| =Backlog (prioritized)= | =BACKLOG-PRIORITIZED= | +| =Dev-Review= | =DEV-REVIEW= | +| =Ångström= | =ÅNGSTRÖM= | +| =!!!= | =TODO= (empty fallback) | + +Note =Dev Review= and =Dev-Review= both produce =DEV-REVIEW= — a same-team collision (handled below). The existing default mapping was effectively slugify already (=Todo=→=TODO=, =In Progress=→=IN-PROGRESS=, …), so slugify reproduces today's keywords for those states. + +** Same-team slug collisions + +When two states in one team slugify to the same keyword (=Dev Review= and =Dev-Review=), the =#+TODO:= line lists the keyword once (de-dup, first-seen by =position= wins its slot). Sync-back resolves that keyword to the *first state by =position=* and logs a one-line warning naming the colliding states, so the behavior is deterministic and visible. Cross-team collisions are deferred (out of scope) — sync still resolves correctly per the heading's own team, but the header can't distinguish them. + +** Derive the =#+TODO:= line + +A new pure helper =pearl--derive-todo-line= takes an ordered list of states (each =(:name :type :position)=) and returns the keyword string: + +- Partition by =type=: done-side = =completed=/=canceled=/=duplicate=; active-side = everything else. +- Within each side, preserve the input order (the caller supplies states already ordered — see Multi-team ordering). +- Slugify each name; de-duplicate by slug, preserving first-seen order. +- Result: ="ACTIVE-1 ACTIVE-2 … | DONE-1 DONE-2 …"=. + +When the state list is empty, return the hardcoded =TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE= so the file is always valid. + +** Multi-team ordering + +=position= is meaningful within a team, not across teams. To keep the header deterministic: + +1. Teams are ordered by *first-seen order in the sorted issue list* (the issues are already sorted before render). +2. Within each team, states are ordered by Linear =position=. +3. The union concatenates teams in that order, then de-dups by slug (first-seen wins), then the derive-line partitions by type. + +So the header order is stable across runs regardless of hash/traversal order. + +** Gather the states (pipeline) + +Because =pearl--team-states= is synchronous-with-cache and =pearl-set-state= already calls it from command context, the render path gathers states synchronously without restructuring into async callbacks: + +1. After issues are normalized and sorted, collect the distinct =LINEAR-TEAM-ID= values in first-seen order. +2. For each team, call =pearl--team-states= (cached after the first hit). Show a progress message while a fetch blocks; on a team's fetch failure, log it and continue (per the coverage guarantee). +3. Build the union: every displayed issue's own state, plus the full states of each team that fetched. Order per Multi-team ordering, de-dup by slug. +4. Hand the union to =pearl--build-org-content=. + +=pearl--build-org-content= stays pure: it gains a =states= argument (the ordered union list) and writes the derived =#+TODO:= via =pearl--derive-todo-line=. The async layer does the synchronous gather just before calling it. One synchronous, cached team-states fetch per distinct team per session — usually one or two; multi-team views do N bounded blocking fetches. + +The team-states query gains =type= and =position= (currently =id name color=), and the cache entry keeps them. + +** Render each issue's keyword + +=pearl--format-issue-as-org-entry= renders the heading keyword as =pearl--state-name-to-keyword(issue-state-name)= instead of =pearl--map-linear-state-to-org=. Safe because the header always includes each displayed issue's own state (coverage guarantee). It also writes a =:LINEAR-STATE-TYPE:= drawer field next to =:LINEAR-STATE-ID:= / =:LINEAR-STATE-NAME:=, so a later merge scan can classify a retained heading onto the correct side of the =|= from the heading itself (see the classification decision). + +** Generated header update on refresh + +The same-source merge refresh must keep the header honest: a refresh can add an issue from a team whose state keyword isn't yet declared, or surface a renamed/added/removed state — *and* it retains existing subtrees that the merge skips. =pearl--merge-issues-into-buffer= keeps a dirty existing subtree (unpushed body edits) without re-rendering it, and keeps a dirty issue that's absent from the refreshed result rather than dropping it. Those headings stay visible after the refresh, so their keywords must be declared too. + +A new helper =pearl--update-derived-todo-header= rewrites the =#+TODO:= line in place (creating it if absent). It derives from the *final displayed buffer*: scan every Linear issue heading (one carrying =LINEAR-ID=) for its current TODO keyword *and its =:LINEAR-STATE-TYPE:= drawer*, union those with the fetched team states (per the ordering rules), classify each onto the active/done side (by type when known; otherwise the fallback in the classification decision), and rewrite the line. =pearl--merge-query-result= calls it after the merge and the state gather, alongside =pearl--update-source-header=. Scanning the final buffer — rather than building from the fetched issue list — is what guarantees retained/skipped subtrees are covered, and it directly validates the invariant: every keyword visible in the buffer is declared on the correct side of the bar. + +** Sync-back: cycled keyword → Linear state + +=pearl--process-heading-at-point= resolves the cycled keyword via the heading's team rather than the removed mapping: + +1. Read the heading's TODO keyword + =LINEAR-TEAM-ID=. +2. =pearl--team-states= (cached) for that team; find the state whose =slugify(name)= equals the keyword (first by =position= on a collision). +3. If a state matches, push it via =pearl--update-issue-state-async= (unchanged; it resolves name → id per team). +4. If *no* state matches (stale buffer keyword after a workflow change, or an old mapped keyword from a pre-upgrade file), the behavior depends on the path. Interactive current-heading sync (an =org-todo= cycle) raises a =user-error= naming the keyword + team and suggesting a refresh or =pearl-clear-cache=. The full-file save scan (=pearl-sync-org-to-linear=, non-=org-todo= path) reports the unknown heading (a =pearl--log= / message) and *skips* it, continuing to the rest — one stale heading must not abort the scan or fail an after-save hook. Neither path ever silently no-ops or pushes a wrong state. + +No persisted reverse map: the keyword is always =slugify(name)=, so the match recomputes from the team's live states. + +** The full-file scan pattern + +=pearl--get-todo-states-pattern= no longer builds from the mapping. The full-file sync scan (=pearl-sync-org-to-linear=, non-=org-todo= path) builds its keyword alternation from the buffer's own =org-todo-keywords-1= (what Org parsed from =#+TODO:=), so it matches whatever the derived header declared, with no stale cache. The =pearl-todo-states-pattern= / =pearl--todo-states-pattern-source= caches are removed with the mapping. + +** User-facing errors + +None of these fail silently: an unknown heading keyword on sync-back (=user-error= naming keyword + team), a missing =LINEAR-TEAM-ID= on a heading being synced, a team state fetch that fails during render (logged + progress/skip message), and a same-team slug collision (logged warning). Each names the offending value. + +* Implementation prerequisites + +- *=WorkflowState.position= — VERIFIED (2026-05-25).* A live =workflowStates { nodes { name type position } }= query returned a float =position= per state (e.g. Dev Review 947.14, In Progress 2, Planning 0), so the per-team ordering works as designed. The no-position fallback is therefore unnecessary; phase 2 can add =position= to the team-states query without an open API question. (Issue queries still fetch only =type=, not =position= — only =pearl--team-states= gains it.) +- Confirm the Linear state =type= enum is =triage/backlog/unstarted/started/completed/canceled/duplicate= (already verified in =docs/issue-query-spec.org=). +- Only =pearl--team-states= gains the =type=/=position= fields. =pearl-get-states-async= / =pearl-get-states= (used by creation flows) fetch =id name color= and are intentionally left unchanged — this feature doesn't depend on them. The two query shapes diverging is acceptable for v1; aligning them is optional follow-up cleanup. + +* Phased implementation plan + +Ordered so dependencies land first. + +1. *Pure core.* =pearl--state-name-to-keyword= and =pearl--derive-todo-line= + their tests (no I/O). Everything else depends on these. +2. *Query + gather.* Add =type=/=position= to the team-states query (after the position verification); add the synchronous gather helper (distinct teams → union with coverage guarantee + multi-team ordering). +3. *Full rebuild.* =pearl--build-org-content= takes =states=, writes the derived header; =pearl--format-issue-as-org-entry= renders via slugify and adds the =:LINEAR-STATE-TYPE:= drawer field. Assert the rebuilt header declares every rendered heading keyword. +4. *Merge refresh.* =pearl--update-derived-todo-header= (final-buffer scan + active/done classification by the heading's =:LINEAR-STATE-TYPE:=, with the org-done-keywords fallback) wired into =pearl--merge-query-result=. +5. *Sync-back.* Team-aware resolve in =pearl--process-heading-at-point= with unknown-keyword refusal; scan pattern from =org-todo-keywords-1=. +6. *Remove the mapping.* Delete =pearl-state-to-todo-mapping=, =pearl--map-linear-state-to-org=, =pearl--map-org-state-to-linear=, =pearl-todo-states-pattern=, =pearl--todo-states-pattern-source=. Replace =test-pearl-mapping.el= with =test-pearl-keywords.el= (keep the regression class: a changed header/keyword set affects full-file scan with no stale cache). +7. *Docs.* README state-mapping section + customization table; migration notes. + +* Test plan + +- =pearl--state-name-to-keyword=: ASCII names, punctuation, repeated punctuation, high-ASCII/accented, double-byte, combining characters, emoji/symbol-only → =TODO=, empty string, =Dev Review= vs =Dev-Review= collision, a real =Todo= alongside an empty-derived =TODO=. +- =pearl--derive-todo-line=: active/done partition *including =duplicate= after the bar*, per-team =position= ordering, deterministic multi-team order, duplicate-slug first-wins, empty-states fallback. +- *Full rebuild*: =build-org-content= with a state set emits the derived =#+TODO=; the header declares every rendered heading keyword; an issue in =Dev Review= renders the keyword =DEV-REVIEW= on its level-2 heading. +- *Merge refresh*: a same-source refresh where a newly fetched issue introduces =DEV-REVIEW= updates the buffer's =#+TODO:= line (creates it if missing). +- *Merge refresh — retained dirty subtree (absent from result)*: an old dirty issue in =QA-REVIEW= is gone from the refreshed result, is kept by the merge, and the rewritten =#+TODO:= still declares =QA-REVIEW=. +- *Merge refresh — skipped dirty subtree (still in result)*: a dirty issue still present is skipped (not re-rendered), and the rewritten header still declares its current kept keyword even if it differs from the fetched issue's new state. +- *Merge refresh — done-side classification of a type-less retained heading*: a retained dirty heading whose keyword was on the done side, whose team-state fetch fails and which lacks =:LINEAR-STATE-TYPE:= (legacy), keeps its keyword *after* the =|= via the org-done-keywords fallback. Same setup for an active retained heading keeps it *before* the bar. A missing/unparseable old header defaults the unknown keyword to active and logs the ambiguity. +- *Drawer carries the type*: a freshly rendered issue's drawer includes =:LINEAR-STATE-TYPE:=, and a merge scan classifies it by that field without the fallback. +- *Partial failure*: a multi-team result where one team's state fetch fails still declares every rendered keyword (issue-own states in the header) and renders without error; the two issue-own fallback states from the failed team order deterministically (first-seen within partition). +- *Sync-back*: =DEV-REVIEW= resolves through the heading's team states; a same-team collision resolves to first-by-position. +- *Unknown keyword by path*: interactive current-heading sync of an unknown keyword raises =user-error=; the full-file scan reports and skips it and still syncs the other headings. +- *Collision metadata*: the pure gather/derive layer returns the colliding slug + states (asserted directly, no =message= capture). +- *Migration/regression*: an old mapped keyword no longer in the derived header does not silently push a wrong state (interactive refuses; scan skips). +- *Scan pattern*: the full-file scan matches a derived keyword present in the buffer's =#+TODO= with no stale-cache dependency. + +* Migration / breaking change + +Removing =pearl-state-to-todo-mapping= is a breaking change for anyone who set it. The package is pre-release (MELPA pending), so no deprecation cycle. The commit is =feat!:= with a =BREAKING CHANGE:= footer. + +*Upgrade path:* after upgrading, *refresh a Pearl file before cycling TODO keywords on it.* An old file's header and headings may use custom-mapped keywords that no longer resolve; cycling one of those now *refuses* (unknown-keyword =user-error=) rather than silently pushing a wrong state, so the safe move is to re-fetch the file so its header and keywords become the derived set. The README migration note must state this because the defcustom is going away. + +* Review dispositions + +Only modified or rejected recommendations, and decisions worth recording, are listed; everything else from the reviews (2026-05-24, rounds 1–4) was accepted as written and woven into the body above. + +** Round 5 (2026-05-25) + +Rubric =Ready with caveats=; the only finding (MP1) was status/rubric hygiene — the spec said READY while still naming the =WorkflowState.position= prerequisite. Resolved the right way: ran the live check (=position= exists, returns a float), recorded it in Status and Implementation prerequisites, and dropped the no-position fallback as unneeded. No spec-design changes. Rubric -> READY. + +** Round 4 (2026-05-24) + +Ready verdict — no blocking findings. The round-3 classification blocker is confirmed resolved (=:LINEAR-STATE-TYPE:= drawer + legacy fallback). The sole caveat, verifying =WorkflowState.position=, was already recorded as an implementation prerequisite. Tidied the one org-lint nit (a literal double-star =DEV-REVIEW= example in the test plan) the reviewer flagged as harmless. + +** Round 3 (2026-05-24) + +- *HP1 "active/done classification of type-less scanned headings" — modified.* The review's fallback (preserve the keyword's side from the buffer's parsed =org-done-keywords=) is a recovery run on every merge. Modified to stop losing the type at the source: render a =:LINEAR-STATE-TYPE:= drawer field (free — the issue query already fetches =type=) so a scanned heading classifies by its own recorded type deterministically. The review's org-done-keywords-side preservation is kept as the *fallback* for legacy headings lacking the field, with default-active-and-log as the last resort. Fully addresses the concern and removes the per-merge reparse for go-forward files. +- *Open question 1 (preserve old side vs default-all-active) — resolved:* preserve the old side, primarily via the stored type, with the parsed-header fallback — not default-all-to-active. +- MP1 (two state-fetch API shapes) accepted: extend =pearl--team-states= only; leave =pearl-get-states-async= unchanged for v1. + +** Round 2 (2026-05-24) + +- *HP1 "retained dirty subtrees in the header" — accepted, option (b).* The review offered two implementations: thread retained/skipped state metadata out of =pearl--merge-issues-into-buffer=, or scan the final buffer. Chose the final-buffer scan — it directly validates the invariant ("every keyword visible is declared"), subsumes the fetched issues, and avoids widening the merge helper's return contract. +- *Open question 1 (scan: abort vs skip-and-continue) — resolved:* skip-and-continue with a report on the full-file scan; =user-error= only on interactive current-heading sync. +- *Open question 2 (final-buffer scan vs returned metadata for merge coverage) — resolved:* final-buffer scan (the HP1 option-(b) choice). +- MP1 (position-less issue-own ordering), MP2 (split unknown-keyword behavior), and MP3 (return collision metadata) accepted as written. + +** Round 1 (2026-05-24) + +- *HP3 "partial team-state fetch failure" — modified.* The review offered two rules: global hardcoded fallback on any failure, or fail the render and leave the file unchanged. Both discard information — the first drops real derived keywords for teams that succeeded; the second leaves the user with nothing. Adopted instead the *header coverage guarantee*: derive the header from the union of each displayed issue's own state (always available from the issue query) plus each successfully-fetched team's full states. This keeps every rendered keyword declared regardless of fetch outcome, and a failed team degrades only to "can't cycle to its absent states." The hardcoded line is reserved for the no-states-at-all case. The review's underlying safety requirement — "the render rule is only safe when the header contains that slug" — is met more completely this way. +- *Review open question 1 (leave-unchanged vs conservative fallback) — resolved* by the HP3 modify above: neither; the coverage-guarantee union. +- *Review open questions 2 and 3 — resolved as decisions:* slugify is Unicode-aware and locale-independent (Q2); same-team collisions resolve to first-by-position with a logged warning (Q3). Both now live in Decisions. + +* vNext / out of scope + +- *Cross-team slug collisions.* Two teams whose states slugify to the same keyword collapse to one keyword in a multi-team file; sync still resolves per the heading's own team, so the push is correct, but the header can't distinguish them. Disambiguation (team-prefixed keywords, per-team =#+TODO= sections) is deferred. +- *Automatic workflow-state cache staleness.* States are cached for the session; a mid-session Linear workflow change needs =pearl-clear-cache=. A TTL/auto-invalidation is deferred. +- *Label-color → tag-face mapping* and other presentation polish. -- cgit v1.2.3