aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
commitb081d62276378b3168c92c06153fd59db0589535 (patch)
tree9be7f7d22e0c9b4a73432fe744c09bb456c671a9 /docs
downloadpearl-b081d62276378b3168c92c06153fd59db0589535.tar.gz
pearl-b081d62276378b3168c92c06153fd59db0589535.zip
feat: pearl — manage Linear issues from org-mode
Pearl fetches Linear issues into an org file and syncs edits back. It covers list / custom views / saved queries, per-issue and bulk rendering with comments inline, conflict-aware sync of descriptions, titles, and comments, field commands for priority / state / assignee / labels, and a transient dispatch menu. The render folds to a scannable outline and nests issues under a sortable parent. Based on and inspired by Gael Blanchemain's linear-emacs.
Diffstat (limited to 'docs')
-rw-r--r--docs/issue-comment-editing-spec.org117
-rw-r--r--docs/issue-conflict-handling-spec.org74
-rw-r--r--docs/issue-query-spec.org258
-rw-r--r--docs/issue-representation-spec.org230
-rw-r--r--docs/issue-sort-order-spec.org65
-rw-r--r--docs/labels-as-org-tags-spec.org81
-rw-r--r--docs/todo-keywords-from-workflow-states-spec.org209
7 files changed, 1034 insertions, 0 deletions
diff --git a/docs/issue-comment-editing-spec.org b/docs/issue-comment-editing-spec.org
new file mode 100644
index 0000000..6a1b0c8
--- /dev/null
+++ b/docs/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 =***** <author> — <timestamp>= 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: <uuid>
+:LINEAR-COMMENT-AUTHOR-ID: <user-uuid or empty>
+:LINEAR-COMMENT-SHA256: <hash of the last-fetched body>
+: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/issue-conflict-handling-spec.org b/docs/issue-conflict-handling-spec.org
new file mode 100644
index 0000000..09acaf6
--- /dev/null
+++ b/docs/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/issue-query-spec.org b/docs/issue-query-spec.org
new file mode 100644
index 0000000..75dcc4b
--- /dev/null
+++ b/docs/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/issue-representation-spec.org b/docs/issue-representation-spec.org
new file mode 100644
index 0000000..912cee9
--- /dev/null
+++ b/docs/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 =*** <TODO-state> <priority> <title>= (=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/issue-sort-order-spec.org b/docs/issue-sort-order-spec.org
new file mode 100644
index 0000000..f49e869
--- /dev/null
+++ b/docs/issue-sort-order-spec.org
@@ -0,0 +1,65 @@
+#+TITLE: pearl — Interactive Sort/Order Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+*DRAFT — design proposal; nothing in =pearl.el= has changed.* Open questions for Craig at the end.
+
+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, that's the natural thing to want.
+
+* Current state
+
+- =pearl--sort-issues= (query spec) 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 query spec documents this split.
+- The active file's =#+LINEAR-SOURCE:= header records the source plist, including any =:sort= / =:order=, so =refresh-current-view= reproduces the ordering.
+
+* Proposed design
+
+** The command
+
+=pearl-set-sort= (interactive), run in the active file:
+
+1. =completing-read= the sort key: =updated=, =created=, =priority=, =title=.
+2. =completing-read= (or a toggle) the order: =asc= / =desc=.
+3. Update the =:sort= / =:order= in the active file's recorded =#+LINEAR-SOURCE:=.
+4. Re-order the view (see below).
+
+A =pearl-toggle-sort-order= convenience command just flips =asc=/=desc= on the current sort and re-orders. Both go on the transient menu (a small "Sort" group, or under View).
+
+** Re-order in place vs refetch
+
+The split matters for whether a sort change needs the network:
+
+- *Client-side sorts* (=priority=, =title=): the issues are already in the buffer. Re-sort in place — reparse the issue subtrees, reorder them, rewrite. No refetch. Fast, works offline.
+- *Server-side sorts* (=created=, =updated=): the ordering comes 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=. Re-run the source (the =refresh-current-view= path) with the updated sort.
+
+So =set-sort= decides: client-side key → re-sort the buffer; server-side key → refetch. The command reports which it did.
+
+** Persistence
+
+The change updates the active file's =#+LINEAR-SOURCE:= so a later =refresh-current-view= keeps the new order. Whether it also writes back to a named saved query in =pearl-saved-queries= is open question 3 — my lean is no by default (the active file is the scratch view; saved queries are the durable definitions), with an explicit "save this ordering to the query" as a separate step.
+
+* Proposed v1 decisions (this feature)
+
+1. =pearl-set-sort= + =pearl-toggle-sort-order=, both on the transient menu.
+2. Client-side keys re-sort the buffer in place; server-side keys refetch.
+3. The change updates the active file's recorded source so refresh preserves it.
+4. Completion is over the known keys/orders, never free text.
+
+* vNext / out of scope
+
+- Multi-key sort (e.g. priority then updated).
+- Per-heading manual reordering that sticks across refresh.
+- Exposing the full Linear =orderBy= surface if the API later un-gates the =[INTERNAL]= per-field sort.
+
+* Open questions for Craig
+
+1. *Command vs transient-only*: a plain =M-x pearl-set-sort= with two completing-reads, or a dedicated transient sub-menu with one-key sort toggles (=p= priority, =u= updated, =t= title, =o= flip order)? The transient reads faster for a frequent action.
+2. *In-place re-sort fidelity*: re-sorting client-side means reparsing and rewriting issue subtrees in the buffer. Acceptable, or prefer always-refetch for simplicity even when a client-side sort wouldn't need it?
+3. *Write-back*: should changing the sort offer to persist it to the originating saved query, or only ever update the active file's header?
diff --git a/docs/labels-as-org-tags-spec.org b/docs/labels-as-org-tags-spec.org
new file mode 100644
index 0000000..a1bb413
--- /dev/null
+++ b/docs/labels-as-org-tags-spec.org
@@ -0,0 +1,81 @@
+#+TITLE: pearl — Render Linear Labels as Org Tags Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+PROPOSED — awaiting review. 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 from the header writer in 952cfe7).
+
+* 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= already 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 will not 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.
+
+* Proposed design
+
+** Tag slugify: label name → org tag
+
+A new pure helper =pearl--label-name-to-tag=. Org tags are restricted to =[[:alnum:]_@#%]= — notably *no hyphens or spaces* (unlike TODO keywords, which allow hyphens). So this slugify differs from the keyword one:
+
+- Downcase (tags are case-sensitive in Org; lowercase is the convention).
+- Replace each run of characters outside =[[:alnum:]_]= with a single =_=.
+- Trim leading/trailing =_=.
+
+Examples: =Bug= → =bug=, =Needs Review= → =needs_review=, =P1= → =p1=, =backend/api= → =backend_api=, =UI/UX= → =ui_ux=. A label that slugifies to empty is dropped.
+
+(Contrast with =pearl--state-name-to-keyword= from the workflow-states spec, which upcases and uses hyphens. Two different targets, two different slugify rules — keep them as separate, clearly-named helpers.)
+
+** Render tags on the issue heading
+
+=pearl--format-issue-as-org-entry= appends the issue's label tags to the heading line in org tag syntax:
+
+: ** TODO [#B] Fix the thing :bug:backend:
+
+Slugify each label name, de-duplicate (preserving order), and join as =:t1:t2:=. No labels → no tag string. The =:LINEAR-LABELS:= drawer stays as the canonical structured store (it holds the exact Linear names, which the tag form lossily slugifies); the heading tags are the derived, org-native view.
+
+Only *issue* headings get tags. The parent view heading and the Comments / individual-comment headings carry none.
+
+** Keep the drawer authoritative; tags are a view
+
+The =:LINEAR-LABELS:= drawer remains the source of truth for the issue's labels (it survives slugify collisions and preserves the display names). =pearl-set-labels= continues to push and rewrite the drawer, and additionally re-renders the heading's tag string so the two stay consistent after a label change.
+
+** v1 is render-only (fetch direction)
+
+Editing the heading's tags by hand does *not* push to Linear in v1. The way to change labels stays =pearl-set-labels= (which now updates both the drawer and the heading tags). Bidirectional sync — parse the heading's tags on save and reconcile them against Linear's label set — is deferred (see out of scope); it needs the same kind of conflict gate the description/title/comment syncs have, and label *creation* semantics (a tag with no matching Linear label) to be decided.
+
+* Files touched
+
+- =pearl.el=: new =pearl--label-name-to-tag=; =pearl--format-issue-as-org-entry= (append tag string to the heading); =pearl-set-labels= (re-render heading tags after a label change). The =:LINEAR-LABELS:= drawer line is unchanged.
+- Tests: new cases for =pearl--label-name-to-tag= (normal, multiword, punctuation, collision, empty); a render test (issue with labels → heading carries the slugified tags); a regression test that =pearl--issue-title-at-point= / title sync still returns the bare title with tags present; a =pearl-set-labels= test that the heading tag string updates.
+- =README.org=: note in the Fields / "active org file" section that labels render as heading tags; mention the drawer remains the structured store.
+
+* Test plan
+
+- *Tag slugify*: =Bug=→=bug=, =Needs Review=→=needs_review=, =UI/UX=→=ui_ux=, =P1=→=p1=, collision de-dup, empty-after-slug dropped.
+- *Render*: an issue with labels =("Bug" "Backend")= renders =** … :bug:backend:= and still carries =:LINEAR-LABELS: [Bug, Backend]= in the drawer.
+- *Title-sync regression*: with tags on the heading, =pearl--issue-title-at-point= returns the bare title (no tags, no keyword), so a no-op title sync still matches and a title edit still round-trips.
+- *set-labels*: after changing labels, the heading tag string reflects the new set (and the drawer too).
+
+* Migration
+
+Additive — no breaking change. Existing files gain heading tags on the next fetch. The removed =#+filetags= is already gone (952cfe7). No defcustom changes.
+
+* Open questions for review
+
+1. *Tag case.* Lowercase (=Bug= → =bug=) for org-tag convention, or preserve Linear's case (=Bug= → =Bug=)? Lowercase is my lean; it matches how most org users tag and avoids =Bug= vs =bug= duplication.
+2. *=#+TAGS:= declaration.* Should the file declare =#+TAGS:= with the union of label slugs (for tag-completion in the buffer), the way the workflow-states spec derives =#+TODO:=? Bonus, not required for the feature. My lean: defer to a follow-up to keep v1 focused.
+3. *Tag inheritance.* Org tag inheritance is on by default, so an issue's tags apply to its Comments subtree in agenda/inheritance contexts. Harmless (a comment "inheriting" =:bug:= rarely matters), but flagging. Disable inheritance for these files, or accept it? My lean: accept it.
+4. *Bidirectional sync* (edit heading tags → push labels) — confirm it's out of scope for v1.
+
+* 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.
diff --git a/docs/todo-keywords-from-workflow-states-spec.org b/docs/todo-keywords-from-workflow-states-spec.org
new file mode 100644
index 0000000..fc0a51c
--- /dev/null
+++ b/docs/todo-keywords-from-workflow-states-spec.org
@@ -0,0 +1,209 @@
+#+TITLE: pearl — Derive Org TODO Keywords from Linear Workflow States Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+READY — implementation-ready, awaiting Craig's final go. Reviews incorporated (2026-05-24, rounds 1–4); round 4 returned a Ready verdict with no blocking findings. Implements the =todo.org= task "Derive the org TODO keywords from the Linear workflow states". One implementation prerequisite remains (verify =WorkflowState.position=; see prerequisites).
+
+* 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
+
+- *Verify =WorkflowState.position=* against the current Linear schema or a live query before implementation. Issue queries fetch state =type= today but not workflow-state =position=. If =position= is unavailable, fall back to the order the API returns states in (still deterministic per fetch) and note 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 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.