diff options
| -rw-r--r-- | docs/local-and-linear-views-spec.org | 448 |
1 files changed, 366 insertions, 82 deletions
diff --git a/docs/local-and-linear-views-spec.org b/docs/local-and-linear-views-spec.org index 82de89e..721908f 100644 --- a/docs/local-and-linear-views-spec.org +++ b/docs/local-and-linear-views-spec.org @@ -4,7 +4,7 @@ * Status -*Draft — awaiting Craig's review.* Triggered by the 2026-06-01 conversation that started as "how do I create a saved query?" and surfaced that the saved-query operations are scattered inconsistently across the verb-first keymap: creation is hidden inside the filter builder and filed under fetch, deletion has a proper home, and editing doesn't exist at all. The conversation converged on a cleaner mental model — one familiar noun, =view=, qualified by where it lives: a *local view* (private, on your machine) and a *Linear view* (published, shared, the =CustomView= Linear users already know). This spec captures that model and the rename + reshape needed to adopt it. +*Ready.* Six Codex review rounds incorporated (2026-06-01). Round one (=Needs research=): all eight open questions dispositioned with Craig and moved to *Agreed decisions*; the copy-down read-API blocker (HP1) cleared by a live probe that verified =CustomView.filterData= and corrected the spec's assumption about its shape (see *Verified API shape for copy-down*). Round two (=Not ready=, naming contract): with Craig's clarification that Pearl has no users, dropped the backward-compatibility/alias layer for direct renames, eliminated user-facing "query", made command names intent-first (=pearl-publish-local-view= / =pearl-save-linear-view-locally=), and defined cross-store duplicate display. Round three (=Not ready=, run/refresh semantics): defined that a tracked local view runs the *local* filter (the source of truth) rather than its Linear mirror, fixing a contradiction the response passes had left. Round four (=Not ready=, =:open=/=nin=): made Pearl's =:open t= predicate (which compiles to =state.type.nin=) a first-class reverse-compile case, resolving the contradiction between the round-trip promise and the blanket =nin= refusal. Round five (=Not ready=, reverse-compile precision): split =:state= from =:state-type=, made =:priority= round-trip to a canonical numeric form, and made multi-id label filters refuse (no plural authoring key in v1). Round six (=Not ready=, two boundary generalizations): generalized multi-value =in= refusal to all singular keys (=:assignee-id=, =:project=, =:cycle=, =:team=, label id), and widened the account-ownership guard from edit/copy to every local-view operation (run, publish, delete) with delete/publish refusing before any Linear call on mismatch. No open findings remain. See *Review dispositions*. Triggered by the 2026-06-01 conversation that started as "how do I create a saved query?" and surfaced that the saved-query operations are scattered inconsistently across the verb-first keymap: creation is hidden inside the filter builder and filed under fetch, deletion has a proper home, and editing doesn't exist at all. The conversation converged on a cleaner mental model — one familiar noun, =view=, qualified by where it lives: a *local view* (private, on your machine) and a *Linear view* (published, shared, the =CustomView= Linear users already know). This spec captures that model and the rename + reshape needed to adopt it. This is the successor framing to =docs/saved-query-sync-spec.org= (which shipped the local→Linear sync) and =docs/issue-sources-spec.org= (which unified the fetch surface behind =pearl-pick-source=). It does not redo their work; it renames and re-homes it so the whole lifecycle speaks one vocabulary. @@ -88,20 +88,20 @@ Two facts the model has to keep honest: ** Vocabulary -Adopt =filter= / =local view= / =Linear view= everywhere user-facing: command names, labels, prompts, transient groups, README, docstrings. "saved query" disappears from the surface (kept only as obsolete aliases, below). +Adopt =filter= / =local view= / =Linear view= everywhere user-facing: command names, labels, prompts, transient groups, README, docstrings. "saved query" disappears from the surface entirely. Pearl has no users yet (not on MELPA, no published repo), so there is no compatibility surface to protect — the renames are *direct*, with no obsolete aliases keeping "query" alive in completion, tests, or docs. Command names are intent-first: a user reaches for "publish this local view" or "save this Linear view locally", not "copy" or "source" jargon. ** Rename map -User-facing renames, each with a back-compat alias (see Migration): +Direct renames — no obsolete aliases. "query" survives only inside internal transport helpers (where it means a GraphQL query) and in this spec's "Current state" / history text describing the pre-change code. -| Today | Proposed | Kind | -|------------------------------------+--------------------------------+------------------------------| -| =pearl-saved-queries= (defcustom) | =pearl-local-views= | =define-obsolete-variable-alias= | -| =pearl-run-saved-query= | =pearl-run-local-view= | =define-obsolete-function-alias= | -| =pearl-delete-saved-query= | =pearl-delete-local-view= | =define-obsolete-function-alias= | -| =pearl-sync-saved-query-to-linear= | =pearl-publish-local-view= | =define-obsolete-function-alias= | -| =pearl-run-view= | =pearl-run-linear-view= | =define-obsolete-function-alias= | -| =pearl-publish-current-source= | =pearl-publish-current-view= | =define-obsolete-function-alias= | +| Today | Renamed to | +|------------------------------------+--------------------------------| +| =pearl-saved-queries= (defcustom) | =pearl-local-views= | +| =pearl-run-saved-query= | =pearl-run-local-view= | +| =pearl-delete-saved-query= | =pearl-delete-local-view= | +| =pearl-sync-saved-query-to-linear= | =pearl-publish-local-view= | +| =pearl-run-view= | =pearl-run-linear-view= | +| =pearl-publish-current-source= | =pearl-publish-current-view= | New commands: @@ -109,12 +109,13 @@ New commands: |------------------------------+------------------------------------------------------------------------------| | =pearl-create-local-view= | Build a filter interactively and save it as a named local view (create slot) | | =pearl-edit-local-view= | Edit an existing local view: re-run the builder pre-seeded with its filter, and adjust name / sort / order | -| =pearl-copy-view-from-linear= | *Copy down.* Pick a Linear view (custom view / favorite), reverse-compile its filter, and save it as a new, independent, editable local view (prompts for a name) | -| =pearl-copy-view-to-linear= | *Copy up.* Publish a local view as a Linear view (the existing sync machinery, framed as copy; re-copy updates the tracked view in place) | +| =pearl-save-linear-view-locally= | *Copy down.* Pick a favorited Linear view, reverse-compile its filter, and save it as a new, independent, editable local view (prompts for a name) | -=pearl-copy-view-to-linear= is the same operation as =pearl-publish-local-view= in the rename map above — copy-up *is* publish. The spec carries both names so the copy/copy symmetry is visible; Open question 7 settles which is the user-facing primary and which is the alias. +Copy-up *is* publish, so its command is =pearl-publish-local-view= (the direct rename of =pearl-sync-saved-query-to-linear=) — no separate "copy to Linear" command. The lifecycle prose still calls the two directions "copy up" and "copy down"; the commands say what the user is doing: =pearl-publish-local-view= and =pearl-save-linear-view-locally=. -=pearl-list-issues-filtered= stays as the *ad-hoc, save-less* filter run (rename optional; see Open question 2). =pearl-create-local-view= becomes the deliberate create path; it may share the builder internals with =pearl-list-issues-filtered= and =pearl-edit-local-view=. +=pearl-create-local-view= is the deliberate create path. =pearl-list-issues-filtered= stays as the ad-hoc filter run and *keeps its inline save offer*, reworded from "save this filter?" to "save this local view?" so the prompt names what it produces. So there are two doors to creating a local view: the dedicated command, and the save offer at the end of an ad-hoc run. The three share builder internals (=pearl-list-issues-filtered=, =pearl-create-local-view=, =pearl-edit-local-view=). + +The unified runner stays =pearl-pick-source=: it picks among favorites, local views, by-dimension filters, and browser-only favorites, so "source" is accurate — it is genuinely more than views, and the =issue-sources-spec= already established the word. Renaming it "run view" would misrepresent what it runs. ** Command surface, by verb @@ -124,26 +125,26 @@ Every view operation hangs off the *view* noun under the verb where a user would - *edit* a local view — =pearl-edit-local-view= - *run* a view (local or Linear) — =pearl-pick-source= (unified) plus direct =pearl-run-local-view= / =pearl-run-linear-view= - *delete* a local view — =pearl-delete-local-view= -- *copy up* a local view → Linear view (publish) — =pearl-copy-view-to-linear= / =pearl-publish-local-view= (and =pearl-publish-current-view=) -- *copy down* a Linear view → local view (fork) — =pearl-copy-view-from-linear= +- *publish* a local view → Linear view (copy up) — =pearl-publish-local-view= (and =pearl-publish-current-view= for the current buffer's view) +- *save locally* a Linear view → local view (copy down, fork) — =pearl-save-linear-view-locally= -** Keymap reshape (proposed — keys open to review) +** Keymap reshape -Goal: the =view= noun reachable under each verb, with a consistent sub-key. The =f= (fetch) group's existing keys collide if both local and Linear view runs want =v=, so one proposal: +The =view= noun is reachable under the verb a user would look for. The view-movement commands (run, publish, save-locally) live in the fetch group alongside the other ways to pull issues into a buffer; create/edit/delete sit under their own verb groups. The copy prefix =C-; L y= keeps its literal copy-to-clipboard meaning (=y u= copy-issue-URL) and gains no view commands — those are "publish" and "save locally", not clipboard copies. -| Chord | Command | -|-------------+-------------------------------| -| =C-; L c v= | =pearl-create-local-view= | -| =C-; L e v= | =pearl-edit-local-view= | -| =C-; L k v= | =pearl-delete-local-view= | -| =C-; L f s= | =pearl-pick-source= (run any view) | -| =C-; L f l= | =pearl-run-local-view= | -| =C-; L f v= | =pearl-run-linear-view= | -| =C-; L f p= | =pearl-copy-view-to-linear= (publish) | -| =C-; L f P= | =pearl-publish-current-view= | -| =C-; L f d= | =pearl-copy-view-from-linear= (copy down) | +| Chord | Command | +|-------------+------------------------------------------| +| =C-; L c v= | =pearl-create-local-view= | +| =C-; L e v= | =pearl-edit-local-view= | +| =C-; L k v= | =pearl-delete-local-view= | +| =C-; L f s= | =pearl-pick-source= (run any view) | +| =C-; L f l= | =pearl-run-local-view= | +| =C-; L f v= | =pearl-run-linear-view= | +| =C-; L f p= | =pearl-publish-local-view= (copy up) | +| =C-; L f P= | =pearl-publish-current-view= | +| =C-; L f d= | =pearl-save-linear-view-locally= (copy down) | -The old =q= (saved query) bindings stay live via the obsolete aliases during the deprecation window; the help labels switch to the new vocabulary immediately. Exact key letters are the most reviewable detail here — the principle (one noun, present under every applicable verb) is the load-bearing part. +The old =q= (saved query) bindings are removed outright in the same change, not deprecated — there are no users to ease through a transition, and Craig's own config is updated alongside the rename. ** Transient reshape @@ -151,7 +152,7 @@ The =pearl-menu= "Workspace" section currently splits Fetch / View / Setup. Rewo ** Picker labels -Reword =pearl--pick-source-candidates= / =pearl--saved-query-scope-label= so the local/Linear split is legible at a glance (exact wording is Open question 6): +Reword =pearl--pick-source-candidates= / =pearl--saved-query-scope-label= so the local/Linear split is legible at a glance: - local-only: =[local] NAME= - published local: =[local → Linear:SCOPE] NAME= (shows the tracking link and where it lives) @@ -159,85 +160,368 @@ Reword =pearl--pick-source-candidates= / =pearl--saved-query-scope-label= so the This directly answers the "which of my views are public?" question Craig wanted to see — the label *is* the public/private indicator. +** Cross-store duplicate display + +The picker draws from two stores, so the same view can show up twice. The rule has to be deterministic before labels and tests can be written: + +- *Same =CustomView= id in both stores* — a published local view (tracking =:linear-view-id= X) and a favorited Linear view that is also X. Dedupe to the *local tracked entry* (=[local → Linear:SCOPE]=): it carries the editable local source, and selecting it runs the *local* filter (see *Run and refresh semantics* below), so you land on the thing you can edit. The favorite is the same server view, so showing it separately would only confuse ("why is this here twice?"). +- *Same name, different or no id* — a local view "My bugs" and an unrelated Linear view "My bugs" are legitimately different and must both stay runnable. Show both, each with its qualified label (=[local]= vs =[linear]=); add the =(#N)= suffix only on an exact display-string collision, which the labels already make rare. +- Copy-down's default name still runs through the local Replace/Rename/Cancel policy; publish still uses Linear Replace/Rename/Cancel for target-scope name collisions. The cross-store rule is only about *display*, not about writing. + +Tests cover same-name local vs Linear, same-id tracked-local plus favorite (deduped), and duplicate Linear favorite titles. + +** Run and refresh semantics + +A published local view has *two* filters once you edit it locally and before you publish again: the local authoring plist (the source of truth) and the last-published Linear =filterData= mirror. The spec has to say which one each command runs, or the implementer is left guessing — and the current code guesses the wrong way for the new model (=pearl--pick-source-candidates= turns a synced entry into =(:type view :id …)= and dispatches through =pearl--query-view-async=, running the server-side mirror). + +v1 makes runtime behavior follow the model — *the local view is the source of truth, so local commands run local*: + +- =pearl-run-local-view= runs the *local authoring filter*, always — even when the entry is tracked (=:linear-view-id= set). It compiles and runs the local plist, not the Linear view. +- =pearl-run-linear-view= runs the *Linear =CustomView= by id* — the server-side version, the way to deliberately run what's published (including any richer filter that only lives on Linear). +- =pearl-pick-source=, after deduping a same-id tracked-local-plus-favorite to the local entry, runs the *local* filter — consistent with the =[local → Linear:SCOPE]= label and the source-of-truth rule. (To run the server-side mirror, use =pearl-run-linear-view=.) +- A buffer rendered from a local view records a =:type filter= source carrying the local view's *name* + filter + sort + order, distinct from an ad-hoc filter's anonymous descriptor; =pearl-refresh-current-view= re-runs that local filter. +- A buffer rendered from a *native* Linear view records a =:type view= source; refresh runs Linear server-side. + +This *changes current behavior*: today a tracked entry runs through the Linear branch; under the new model it runs local. The change is the point — reusing =:type view= for a tracked local view would preserve the old behavior but undermine the ownership model. The local-vs-mirror divergence window (after edit, before publish) is real but unflagged in v1; a visible "unpublished changes" indicator is vNext. + +** Performance bounds + +v1 is deliberately cheap, and the spec states the bounds so they don't drift: + +- Copy-down lists *favorited* Linear views only (no all-Custom-Views enumeration, no pagination), and fetches =customView(id){ filterData }= lazily, only after the user selects one. Reverse-compile runs on a single small filter tree. +- Publish reuses the existing single-view create/update path. +- The unified runner fetches favorites fresh (as today) and resolves team names for =Linear:SCOPE= labels only when at least one tracked local view exists — the existing conditional, kept. + +All-Custom-Views copy-down stays vNext precisely because it is the part that needs pagination and a cache path. + +** Local view persistence and edit semantics + +Create, edit, and copy-down all write a local-view entry, and the current writer is not safe for them. =pearl--save-query= rebuilds an entry from =:filter= / =:sort= / =:order= only, so reusing it to edit a *published* local view would silently drop the tracking keys (=:linear-view-id=, =:linear-view-team-id=, =:linear-view-shared=, =:linear-view-url=, =:linear-view-synced-at=) and any =:account= or =:copied-from-view-id=. A user who edits a published local view and then copies up again expects the *same* Linear view to update; a dropped link turns that into a fresh publish and a duplicate Linear view. (=pearl--save-query-mark-synced= already does the right thing for its own keys — copy the plist, =plist-put= the changed keys, leave the rest.) + +- *Metadata-preserving writer.* Introduce =pearl--save-local-view= that copy-updates the existing entry by name, replacing only the fields the operation changed and preserving every other key. Create/edit/copy-down all go through it. The only path that clears =:linear-view-*= is an explicit future "unlink" operation (out of scope here). +- *Edit = pre-seeded re-run.* =pearl-edit-local-view= re-opens the builder seeded with the stored filter, and lets the user adjust the name, sort, and order. It writes through =pearl--save-local-view=, so a published view stays published and a copied-down view keeps its provenance. +- *Account tag (multi-account) — one guard, every operation.* A local view created or copied down under =pearl-accounts= is stamped with the active =:account=. The guard that =pearl-run-saved-query= already applies (refuse an entry tagged to a non-active account) extends to *every* local-view operation that reads or mutates an entry: run, edit, publish, publish-current-view, delete/unlink, and save-over. A single =pearl--require-local-view-account= helper enforces it so the check isn't re-derived per command. The remote-mutating ones matter most: publishing or deleting under the wrong account resolves ids against the wrong workspace or targets the wrong remote — so on an account mismatch, =pearl-delete-local-view= refuses *before* any =customViewDelete=, and =pearl-publish-local-view= refuses before any =customViewCreate/Update=. Copy-down only lists and fetches favorited Linear views from the *active* account and stamps the new local view with it. Legacy single-account mode and untagged legacy entries behave exactly as today (no guard). + +*Local name-collision policy.* =pearl--save-query= today replaces a same-name entry with no prompt — fine when the only writer was the explicit create, dangerous now that copy-down and edit-rename can land on an existing name. Local views are user-authored config, so a silent overwrite is data loss. One policy applies to create, edit-rename, and copy-down: when the target local name already exists, prompt Replace / Rename / Cancel. Default to *Rename* for copy-down and edit-rename (the common case is "I want a new one"); require an explicit confirm for *Replace*. This mirrors the Replace/Rename/Cancel flow copy-up already uses against Linear, so the two collision surfaces read the same. + ** Copy up (local view → Linear view) -This is the existing publish/sync path, unchanged in behavior. =pearl-copy-view-to-linear= picks a local view, creates a =CustomView= on first copy (prompting scope + visibility), and on a re-copy updates the tracked view in place via =customViewUpdate= against the stored =:linear-view-id=. Name collisions in the target scope prompt Replace/Rename/Cancel. The only change here is vocabulary: "sync" becomes "copy up", and the command is presented as the symmetric partner of copy-down. No new engine work. +This is the existing publish/sync path, unchanged in behavior. =pearl-publish-local-view= (the direct rename of =pearl-sync-saved-query-to-linear=) picks a local view, creates a =CustomView= on first publish (prompting scope + visibility), and on a re-publish updates the tracked view in place via =customViewUpdate= against the stored =:linear-view-id=. Name collisions in the target scope prompt Replace/Rename/Cancel. The only change here is vocabulary: "sync" becomes "publish" (the lifecycle's copy-up direction). No new engine work. + +** Verified API shape for copy-down (probed 2026-06-01) + +A live probe against =https://api.linear.app/graphql= settled the read-API blocker (HP1) and, more usefully, corrected an assumption the first draft got wrong. + +- *=CustomView.filterData= is readable and non-null.* Introspection: =filterData= is =NON_NULL JSONObject=. =customViews(first:N){ nodes { id name filterData } }= returns it for every Custom View the user can access. So the read path exists: enumerate Custom Views, read =filterData=. +- *But its shape is NOT the =IssueFilter= input Pearl emits.* The first draft assumed =filterData= would mirror =pearl--build-issue-filter='s output (a flat AND of sibling keys). It does not. Linear stores the *view-filter* serialization, which is an =and=/=or= tree: + - The top level is wrapped in ={ "and": [ ... ] }= even for a single condition. + - Single sub-conditions are often wrapped in their own =or= array, e.g. =assignee: { "or": [ { "isMe": { "eq": true } } ] }=. + - Scalars are expressed as =in= arrays, e.g. =project: { "id": { "in": [ID] } }= and =state: { "name": { "in": ["Needs Review"] } }= — where Pearl's compiler emits the =eq= scalar form. + - An unfiltered grouping view returns ={}= (no constraints). + - Richer real views appear too, e.g. a label condition serialized as =labels: { "and": [ { "or": [ { "name": { "eq": "Chore" } }, { "parent": { "name": { "eq": "Chore" } } } ] } ] }= — OR plus a label =parent= dimension Pearl does not model. + +The consequence: copy-down is not a one-to-one inversion of Pearl's compiler. It is a *normalize-then-match* over Linear's view-filter tree. ** Copy down (Linear view → local view) -This is the one direction that needs new engine work, and Craig wants the result *editable and renamable* — so an opaque, run-only copy is not enough. The local view it produces has to carry a real authoring filter that =pearl-edit-local-view= can re-open in the builder. +Copy-down forks a Linear view into a new, editable, renamable local view. Because the result must be editable, an opaque run-only copy is not enough — the local view has to carry a real authoring plist that =pearl-edit-local-view= can re-open. + +*Reverse-compile = normalize, then match.* -*The reverse-compile.* Pearl's filter compiler is one-way today: authoring plist → =IssueFilter= JSON (=pearl--build-issue-filter=). Copy-down needs the inverse — read a Linear view's =filterData= (an =IssueFilter=) and reconstruct the authoring plist. The compiler's output is a flat AND of a fixed dimension set, each with one operator shape: +1. *Normalize* the view-filter tree toward Pearl's flat-AND model: flatten the top-level =and= array into a conjunction set; unwrap any single-element =and= / =or= wrapper (a one-branch =or= is not a real disjunction); read an =in= array of length one as the scalar value Pearl's authoring keys expect. +2. *Match* each normalized conjunct against a Pearl authoring dimension and emit the authoring key: -| Authoring key | =IssueFilter= shape Pearl emits / inverts | -|--------------------------------+---------------------------------------------------| -| =:assignee :me= | =assignee.isMe = true= | -| =:assignee= EMAIL | =assignee.email.eq= | -| =:assignee-id= ID | =assignee.id.eq= | -| =:state= ... | =state= (per =pearl--compile-state-filter=) | -| =:project= ID | =project.id.eq= | -| =:team= KEY | =team.key.eq= | -| =:labels= (NAMES) | =labels.some.name.in= | -| =:label-id= ID | =labels.some.id.in= | -| =:priority= ... | =priority.eq= | -| =:cycle= ID | =cycle.id.eq= | +| Authoring key (emit) | Normalized view-filter conjunct it matches | Shape status | +|------------------------+-------------------------------------------------------+---------------------------| +| =:assignee :me= | =assignee.isMe.eq = true= (after unwrapping =or=) | primary | +| =:assignee-id= ID | =assignee.id.in=[ID]= / =.eq= (after unwrapping =or=) | primary (builder default) | +| =:assignee= EMAIL | =assignee.email.eq= | legacy / back-compat | +| =:state= NAME | =state.name.eq= / single =state.name.in=[NAME]= | primary (=pearl--compile-state-filter=) | +| =:state-type= TYPE(S) | =state.type.eq= / =state.type.in=[TYPE…]= (one or many) | primary (=pearl--compile-state-filter=) | +| =:open t= | =state.type.nin=["completed","canceled","duplicate"]= (order-insensitive, exactly that set) | primary — Pearl's open predicate | +| =:project= ID | =project.id.in=[ID]= / =.eq= | primary | +| =:team= KEY | =team.key.eq= | primary | +| =:label-id= ID | =labels.some.id.eq= / single =labels.some.id.in=[ID]= | primary (rename-proof); multi-id refuses | +| =:labels= (NAMES) | =labels.some.name.in=[NAME…]= | legacy / back-compat | +| =:priority= N | =priority.eq=N= (N = 0..4) | primary (canonical numeric) | +| =:cycle= ID | =cycle.id.in=[ID]= / =.eq= | primary | -The reverse-compile recognizes exactly these shapes and inverts them. Round-trip tests (authoring plist → =build-issue-filter= → reverse → identical plist) are the spine of the phase. +The id-based forms are what the current interactive builder writes (=:assignee-id=, =:label-id=) and are rename-proof; the email/name forms remain for old hand-authored entries and must round-trip too, but they are back-compat, not the target a fresh copy-down produces. Round-trip tests run both ways (authoring plist → =build-issue-filter= → reverse → identical plist, with the priority and label caveats below) per dimension, plus normalize-from-real-view-filter tests for the wrapper and =in=[single]= cases. -*The representability boundary — and why copy-down refuses rather than guesses.* Linear's =IssueFilter= is far richer than Pearl's authoring model. A Linear view can express: +Three current compiler shapes need a precise rule, because the compiled Linear filter loses information the authoring plist had: -- *OR logic and nested =and=/=or=* — Pearl's authoring filter is flat AND only. -- *Operators Pearl doesn't emit* — =neq=, =nin=, =null=, date/number comparators (=gt=, =lt= on =createdAt=, =dueDate=, =estimate=), =labels.every= / =labels.none= (Pearl only does =some=). -- *Dimensions Pearl doesn't model* — creator, subscribers, parent, project milestone, estimate, due date, and others. +- *=:state= vs =:state-type= are distinct keys.* =:state= NAME= compiles to =state.name.eq=; =:state-type= TYPE-OR-LIST= compiles to =state.type.in=. The reverse must emit the matching key — =state.type.in= becomes =:state-type=, never =:state= — and both still take precedence over =:open= as =pearl--compile-state-filter= does. (The first draft collapsed both into one =:state= row, which couldn't round-trip a type filter.) +- *=:priority= canonicalizes to the integer.* =pearl--compile-priority= maps both =(:priority high)= and =(:priority 2)= to =priority.eq=2=, so the symbol-vs-integer choice is not recoverable. Copy-down therefore emits the *canonical numeric* form: =priority.eq=N= reverse-compiles to =(:priority N)= for N in 0..4; any other value refuses. The "identical plist" round-trip promise holds with this one documented canonicalization — a symbol-authored filter round-trips to its numeric equivalent, which compiles to the same Linear filter. +- *=:label-id= is singular.* Pearl has no plural =:label-ids= authoring key, so =labels.some.id= with one id (=.eq= or one-element =.in=) emits =:label-id=, and =labels.some.id.in= with more than one id *refuses* in v1 (refuse-don't-guess — collapsing to one id or inventing a key would both be wrong). =labels.some.name.in= with any number of names stays representable as =:labels=. -When a Linear view's filter contains anything outside the table above, copy-down *refuses with a clear message naming the unsupported construct* ("this Linear view uses OR logic / a due-date filter / a creator filter that Pearl's local views can't represent yet — run it directly with =pearl-run-linear-view= instead"). It does *not* silently drop the unsupported conditions: a local view that looks like the Linear view but quietly matches a different set of issues is the worst possible outcome — every refresh would diverge invisibly. Refuse-don't-guess is the rule. +*The general singular-vs-plural =in= rule.* The label case is one instance of a rule that covers every dimension: *a singular authoring key accepts only =eq= or a one-element =in=; a multi-value =in= on a singular key refuses.* Pearl's singular keys are =:assignee-id=, legacy =:assignee= (email), =:project=, =:cycle=, =:team=, and =:label-id= — so =assignee.id.in=[A,B]=, =assignee.email.in=[A,B]=, =project.id.in=[P1,P2]=, =cycle.id.in=[C1,C2]=, =team.key.in=[T1,T2]=, and multi-id =labels.some.id.in= all refuse: no =:assignee-ids= / =:project-ids= / … key exists, and collapsing to one value would silently narrow the view. The only keys that accept a multi-value =in= are the two genuinely plural ones — =:labels= (from =labels.some.name.in=[NAMES…]=) and =:state-type= (from =state.type.in=[TYPES…]=). Plural authoring keys for the singular dimensions are vNext. -This bounds the feature honestly: copy-down works for any Linear view whose filter lives inside Pearl's authoring model (the common case — a team + state + label + assignee AND-filter), and refuses, legibly, for the richer ones. The refused set shrinks over time as the authoring model grows (OR support is its own future spec). +*=:open= is the one recognized negation.* Pearl's =:open t= is the common "not closed" predicate and compiles (via =pearl--compile-state-filter=) to =state.type.nin=["completed","canceled","duplicate"]= — and Pearl's favorite-derived source filters (=(:project ID :open t)=, =(:label-id ID :open t)=, =(:assignee-id ID :open t)=) lean on it heavily. The reverse-compiler matches *exactly* that =state.type.nin= set, order-insensitive, back to =:open t=. This is the lone =nin= the matcher accepts; every other =nin= still refuses (see below). When a normalized view also carries an explicit =state.name= or =state.type= condition, that takes precedence over =:open= exactly as the compiler does, so the round-trip stays faithful. -*Source of the copy-down candidate.* Copy-down reuses the picker's Linear-view enumeration (custom views + favorites that resolve to views) so the source list is the same one =pearl-run-linear-view= / =pearl-pick-source= already build. The chosen view's =filterData= is fetched, reverse-compiled, and saved as a new local view under a prompted name (defaulting to the Linear view's name), with no =:linear-view-id= (it's a fork). Optional =:copied-from-view-id= provenance is recorded for reference only. +*Representability boundary — refuse, don't guess (Open decision 3).* After normalization, anything Pearl's authoring model can't hold makes copy-down *refuse with a message naming the construct*, rather than drop it: + +- a *real* multi-branch =or= or nested =and= / =or= that survives normalization (genuine disjunction); +- a dimension Pearl doesn't model — =parent=, =creator=, =subscribers=, =dueDate=, =estimate=, project milestone, and the rest; +- an operator outside the matched set — =neq=, generic =nin= (except Pearl's exact open-state predicate, matched to =:open t= above), =null=, date/number comparators, =labels.every= / =labels.none=; +- a multi-value =in= on any *singular* key — =assignee.id.in=, =assignee.email.in=, =project.id.in=, =cycle.id.in=, =team.key.in=, and multi-id =labels.some.id.in= — refuses, because Pearl has no plural authoring key for those dimensions and collapsing to one value would silently narrow the view. Only =labels.some.name.in= (=:labels=) and =state.type.in= (=:state-type=) accept multiple values. + +A diagnostic helper returns a *structured reason* (the offending path + construct), and the command formats it: "this Linear view filters on a label parent / uses OR logic / filters by due date, which Pearl's local views can't represent yet — run it directly with =pearl-run-linear-view= instead." A local view that looks like the Linear view but quietly matches a different issue set, diverging on every refresh, is the worst outcome — so copy-down never produces a lossy local view. The probe's own sample bears this out: of seven real Custom Views, the team/project/state/assignee ones normalize cleanly, the "Chore" label-parent-OR view refuses, and the three empty grouping views (={}=) copy down to a no-constraint filter that =pearl-create-local-view='s own "needs at least one constraint" guard then rejects (so an empty view refuses too, with that message). + +*Candidate source (Open decision 9): favorited Linear views only, v1.* Copy-down lists the user's *favorited* Linear views — favorites whose normalized =:kind= is =view=, which already carry a Custom View id. That set is small and already bounded, so it reuses the existing favorites enumeration and sidesteps Custom-View pagination entirely. Empty state names the fix: "favorite a Linear view in Linear first, then copy it down." Enumerating *all* Custom Views (favorited or not) via a paged =filterData= query is deferred to vNext; it is the only part that needs pagination and a separate cache path. + +*Mechanics and messages (MP3).* The chosen view's =filterData= is fetched (one =customView(id){ filterData }= request), normalized, and reverse-compiled. On success it is saved through =pearl--save-local-view= as a new local view under a prompted name (defaulting to the Linear view's name, subject to the local collision policy above), with no =:linear-view-id= — it is a fork — and an optional =:copied-from-view-id= recorded for provenance only. Progress and result messages: "Fetching filter for <view>…", the refusal message above when unrepresentable, and a success message that states the independence so the user isn't surprised later: "Saved Linear view <name> as independent local view <name>; edits won't update the original Linear view until you publish it back." + +*Helpers (architecture).* Keep the command thin over pure helpers, matching Pearl's existing compiler-plus-wrapper shape: =pearl--reverse-compile-issue-filter= (normalize + match, returns the authoring plist or a structured refusal), =pearl--issue-filter-representable-p= (the diagnostic, returns reasons not formatted strings), =pearl--save-local-view= (the metadata-preserving writer), and a candidate-builder separate from =completing-read=. * Migration -- =pearl-saved-queries= is a public defcustom set in users' =init.el=. Rename to =pearl-local-views= with =define-obsolete-variable-alias 'pearl-saved-queries 'pearl-local-views "pearl 1.1"= so existing configs keep working and Customize forwards transparently. The alias reads and writes the same storage; no data migration of the alist contents is needed (the =:filter= / =:linear-view-*= shape is unchanged). -- Each renamed command gets =define-obsolete-function-alias= so muscle memory and any user keybindings survive the deprecation window. +- =pearl-saved-queries= is renamed to =pearl-local-views= directly — no =define-obsolete-variable-alias=. Pearl has no external users, so there is no config in the wild to forward. The on-disk alist shape (=:filter= / =:sort= / =:order= / =:linear-view-*=) is unchanged, so the stored data carries over untouched; only the variable name changes. +- Commands are renamed directly too — no =define-obsolete-function-alias=. The old =q= saved-query keybindings are removed in the same change rather than deprecated. +- *Craig's own config is the only thing to update* (he's the sole user, dogfooding). Anything in his =init.el= that sets =pearl-saved-queries= or binds the old commands/keys is updated alongside the rename, in the same change. There is no alias safety net and none is wanted — the point of the rename is that "query" stops existing. - =#+LINEAR-SOURCE= file headers are unaffected — they record a source descriptor, not the command name. Internal source plists (=:type 'view= / =:type 'filter=) may be renamed in lockstep or left as-is; they are not user-facing. - README, =package-summary.md=, and the transient help strings switch to the new vocabulary in the same change. -* Open questions (for Craig) - -1. *Variable name:* =pearl-local-views= (recommended), =pearl-saved-views=, or keep =pearl-saved-queries= as the storage name and only relabel the UI? Recommend the rename with an obsolete-alias — the whole point is that the vocabulary is consistent end to end. -2. *Ad-hoc builder:* does =pearl-list-issues-filtered= keep its inline "save this filter?" prompt, or does creation move entirely to =pearl-create-local-view= (leaving =pearl-list-issues-filtered= as a pure save-less ad-hoc run)? Recommend a dedicated create command + keep the inline prompt too (two doors to the same create), unless you'd rather have exactly one create path. -3. *Copy-down representability — refuse or fall back?* When a Linear view's filter is outside Pearl's authoring model (OR logic, unmodeled dimension, comparator), the recommendation is *refuse with a clear message* and point the user at =pearl-run-linear-view=. The alternative is a fallback opaque, run-only local copy that =pearl-edit-local-view= would reject. Recommend refuse — a silently-lossy local view that diverges on every refresh is the worse failure. Confirm. -4. *Edit semantics:* re-run the builder pre-seeded with the stored filter (recommended, reuses the builder), or a field-by-field editor? Pre-seeded re-run is simpler and matches how filters are authored. -5. *Keymap letters* for local-view run vs Linear-view run in the =f= group (the =v= collision), and the copy-up / copy-down slots (=f p= / =f d= proposed). The table above is a proposal; your call on the letters. -6. *Picker label wording* — exact strings for local / published / native-Linear entries. -7. *Copy-up naming:* is =pearl-copy-view-to-linear= the user-facing primary (with =pearl-publish-local-view= the alias), or the reverse? "Copy up / copy down" is symmetric and matches how you described it; "publish" is the more conventional word for pushing something to a shared place. Recommend leading with =pearl-copy-view-to-linear= for symmetry and keeping =pearl-publish-local-view= as the alias. -8. *Copy-down tracking:* the recommendation is a *fork* — the new local view carries no =:linear-view-id=, only optional =:copied-from-view-id= provenance, so editing and renaming it never fights a sync link. The alternative is to track the source so copy-up later updates that same Linear view. Recommend fork (you copy down to diverge; copy-up afterward makes a new tracked Linear view). Confirm. +* Agreed decisions + +All settled with Craig on 2026-06-01, across the first open-question pass and the second (naming-contract) review. Nothing here is still open. + +1. *No backward compatibility* — Pearl has no users, so the renames are direct: no obsolete variable/function aliases, and the old =q= saved-query bindings are removed outright. Craig's own config is updated in the same change. +2. *Variable name* — =pearl-saved-queries= → =pearl-local-views= (direct rename). +3. *Eliminate user-facing "query"* — "query" survives only inside internal GraphQL transport helpers and historical text. Nothing a user types, reads, or completes says "query". +4. *Intent-first command names.* Copy-up's command is =pearl-publish-local-view= (publish is the discoverable word, not "copy to Linear"). Copy-down's command is =pearl-save-linear-view-locally=. "Copy up / copy down" stays as lifecycle prose only. +5. *Unified runner stays =pearl-pick-source=* — it runs favorites, local views, by-dimension filters, and browser-only favorites, so "source" is accurate and it's the word =issue-sources-spec= already established. Not renamed to a view-only word. +6. *Ad-hoc builder* — =pearl-list-issues-filtered= keeps its inline save offer, reworded to "save this local view?". A dedicated =pearl-create-local-view= is the other door. +7. *Keymap* — view-movement commands (run / publish / save-locally) sit in the fetch group (=f l= / =f v= / =f p= / =f d=); create/edit/delete under =c v= / =e v= / =k v=. The copy prefix =C-; L y= keeps its clipboard meaning only. +8. *Copy-down representability* — refuse with a structured, named reason; never a lossy or opaque copy. +9. *Copy-down tracking* — fork: no =:linear-view-id=, only optional =:copied-from-view-id= provenance. +10. *Edit semantics* — pre-seeded builder re-run that preserves all non-edited metadata (via =pearl--save-local-view=). +11. *Local name-collision policy* — Replace / Rename / Cancel for create, edit-rename, and copy-down; default Rename for copy-down and edit-rename; explicit confirm for Replace. +12. *Cross-store duplicate policy* — when a published local view and a favorited Linear view point at the *same* =CustomView= id, the picker dedupes to the local tracked entry (it carries the editable source). A local view and a Linear view that merely share a *name* (different or no id) are both shown with their qualified labels; the =(#N)= suffix appears only on an exact display collision. +13. *Copy-down candidate source* — favorited Linear views only in v1; all-Custom-Views enumeration deferred to vNext. +14. *Picker labels* — =[local] NAME= / =[local → Linear:SCOPE] NAME= / =[linear] TITLE=. +15. *Account behavior* — local views created or copied down under =pearl-accounts= are stamped with the active =:account=; edit preserves it. One guard refuses *every* read/mutate operation (run, edit, publish, publish-current, delete/unlink, save-over) on a cross-account entry; delete and publish refuse before touching Linear. Legacy single-account / untagged entries are unguarded. +16. *Run/refresh semantics* — the local view is the runtime source of truth: =pearl-run-local-view= and a deduped =pearl-pick-source= selection run the *local* filter even for a tracked entry; =pearl-run-linear-view= runs the server-side mirror. A local-view buffer is a =:type filter= source that refreshes via its local filter. This reverses the current code, which runs tracked entries through the Linear branch. * Acceptance criteria -- Every view operation (create, edit, run, delete, publish) is reachable under the verb a user would predict, all hanging off the =view= noun. -- =pearl-saved-queries= and every renamed command keep working via obsolete aliases; an existing =init.el= needs no edit. -- The picker, transient, keymap help, prompts, README, and docstrings all speak =filter= / =local view= / =Linear view=; "saved query" appears only in the obsolete-alias declarations. -- =pearl-create-local-view= and =pearl-edit-local-view= exist and round-trip a local view (create → edit → run → copy up → delete) with tests. -- Copy down works: a representable Linear view reverse-compiles into an editable local view, and the round-trip (authoring plist → =build-issue-filter= → reverse-compile → identical plist) holds for every dimension in the table, with Normal/Boundary/Error tests. -- Copy down refuses, with a message naming the unsupported construct, for a Linear view whose filter uses OR logic, an unmodeled dimension, or an operator outside the authoring set — and never produces a lossy local view. -- Copy up (=pearl-copy-view-to-linear=) creates a tracked Linear view on first copy and updates it in place on re-copy, unchanged from the current sync behavior. +- Every view operation (create, edit, run, delete, publish, save-locally) is reachable under the verb a user would predict, all hanging off the =view= noun. +- *Naming regression:* no user-facing command, prompt, transient label, defcustom, or README / package-summary text contains "saved query" or a bare "query" for this feature. The old =pearl-saved-queries= / =pearl-*-saved-query= / =pearl-run-view= symbols are gone (renamed, not aliased), and a grep for them over the user-facing surface returns nothing. "query" survives only in internal GraphQL helpers. +- Command-name coverage for the public API: =pearl-create-local-view=, =pearl-edit-local-view=, =pearl-run-local-view=, =pearl-delete-local-view=, =pearl-publish-local-view=, =pearl-save-linear-view-locally=, =pearl-run-linear-view=, =pearl-pick-source=. +- The picker, transient, keymap help, prompts, README, and docstrings all speak =filter= / =local view= / =Linear view= / =publish= / =save locally=. +- =pearl-create-local-view= and =pearl-edit-local-view= exist and round-trip a local view (create → edit → run → publish → delete) with tests. +- *Edit preserves metadata:* editing a published local view keeps =:linear-view-*=, =:account=, and =:copied-from-view-id=, changing only the requested fields; a later copy-up updates the same Linear view by id rather than publishing a duplicate. Tested. +- *Local collisions are safe:* create, edit-rename, and copy-down prompt Replace/Rename/Cancel on a name clash and never overwrite by default. Tested for all three. +- *Copy-down round-trip:* every =pearl--build-issue-filter= shape reverse-compiles back to its authoring plist (with the priority canonicalization noted below) — =:assignee :me=, =:assignee-id=, legacy =:assignee= email, =:state=, =:state-type=, =:open t=, =:project=, =:team=, =:labels=, =:label-id=, =:priority=, =:cycle= — and the normalizer handles the real view-filter wrappers (top-level =and=, single-element =or=, =in=[single]=). Normal/Boundary/Error. +- *State keys are distinct:* =(:state "In Progress")= round-trips to =:state=; =(:state-type "started")= and =(:state-type ("started" "unstarted"))= round-trip to =:state-type= (single and list); =state.type.in= never reverse-compiles to =:state=. =:state= / =:state-type= take precedence over =:open=. +- *Priority canonicalizes to numeric:* =(:priority 2)= round-trips exactly; =(:priority high)= builds to =2= and reverse-compiles to canonical =(:priority 2)=; =priority.eq= outside 0..4 refuses. +- *Label id is singular:* single =labels.some.id= round-trips to =:label-id=; =labels.some.id.in= with more than one id refuses (no =:label-ids= key in v1); =labels.some.name.in= round-trips to =:labels=. +- *=:open t= specifically:* =(:open t)= and the favorite-derived =(:project ID :open t)= / =(:label-id ID :open t)= / =(:assignee-id ID :open t)= round-trip; =state.type.nin= with the exact open-state set (any order) maps to =:open t=, while =state.type.nin= with any other set refuses with a structured reason. +- *Copy-down refuses* with a structured reason naming the construct for =or= with multiple branches, surviving nested =and=/=or=, =neq=, generic =nin= (other than the open predicate), =null=, date comparators, =labels.every= / =labels.none=, label =parent=, and unknown dimensions — and never produces a lossy local view. The empty-filter (={}=) view refuses via the create guard. +- *Copy-down candidate builder* (fixture/integration) handles favorited Custom Views, duplicate titles, stale ids, =nil= / missing =filterData=, and GraphQL errors. +- *Account:* new local views get the active =:account=; edit preserves it. Run, edit, publish, publish-current-view, and delete all refuse on a cross-account entry; delete makes no =customViewDelete= call and publish makes no create/update call on mismatch. Tested for each. +- *Singular multi-value refusal:* =assignee.id.in=, =assignee.email.in=, =project.id.in=, =cycle.id.in=, =team.key.in=, and multi-id =labels.some.id.in= each refuse with a structured reason; the plural =labels.some.name.in= (=:labels=) and =state.type.in= (=:state-type=) accept multiple values. Tested. +- *Cross-store duplicates:* same-=CustomView=-id tracked-local-plus-favorite dedupes to the local entry; same-name different-id local and Linear views both show with qualified labels. Tested. +- *Tracked-local run/refresh:* =pearl-run-local-view= and a deduped =pearl-pick-source= selection run the local authoring filter (not =pearl--query-view-async=) even when the entry is tracked; a local-view buffer refreshes via its local filter, a native Linear-view buffer refreshes server-side. Tested, including the edited-but-unpublished case where local and Linear diverge. +- Publish (=pearl-publish-local-view=) creates a tracked Linear view on first publish and updates it in place on re-publish, unchanged from the current sync behavior. - Full ERT suite green, =make lint= and byte-compile clean. * Implementation phases (commits) -1. *Rename + aliases* — rename the defcustom and the four commands, add obsolete aliases, reword docstrings. No behavior change. (=refactor:=) -2. *Create + edit* — =pearl-create-local-view=, =pearl-edit-local-view=, builder extraction shared with =pearl-list-issues-filtered=. Tests. (=feat:=) +1. *Direct rename* — rename the defcustom and the commands to the local/Linear view vocabulary, remove the old =q= saved-query bindings, update Craig's config, reword docstrings. No aliases. No behavior change. (=refactor:=) +2. *Create + edit* — =pearl-create-local-view=, =pearl-edit-local-view=, the metadata-preserving =pearl--save-local-view= writer, the shared local name-collision prompt (Replace/Rename/Cancel), account stamping/preservation, builder extraction shared with =pearl-list-issues-filtered= (whose save offer rewords to "save this local view?"). Tests for metadata preservation, collisions, and account behavior. (=feat:=) 3. *Keymap + transient reshape* — the view noun under each verb; help labels. Tests for the keymap. (=feat:=) -4. *Picker labels* — reword =pearl--pick-source-candidates= / scope-label; tests. (=refactor:= / =feat:=) -5. *Copy up* — present the existing publish/sync as =pearl-copy-view-to-linear= (alias over =pearl-publish-local-view=); transient + keymap slot; no behavior change. (=feat:= / =refactor:=) -6. *Copy down — the reverse-compile* — =pearl--reverse-compile-issue-filter= (inverts every shape in the dimension table; refuses outside it), =pearl-copy-view-from-linear= (fetch =filterData=, reverse-compile, save as a forked local view), keymap + transient slot. Round-trip + refusal tests are the load-bearing coverage. (=feat:=) -7. *README + package-summary* — document filter / local view / Linear view, the git-style lifecycle, the copy-up / copy-down directions and the representability boundary, and the full create/edit/run/copy/delete surface. (=docs:=) +4. *Picker labels + cross-store dedup + dispatch* — reword =pearl--pick-source-candidates= / scope-label; implement the same-id dedupe-to-local and same-name show-both rules; make a deduped tracked-local entry dispatch to the *local* filter (not the Linear branch); record =:type filter= source descriptors for local-view buffers. Tests. (=refactor:= / =feat:=) +5. *Publish (copy up)* — present the existing publish/sync as =pearl-publish-local-view=; transient + keymap slot; no behavior change. (=refactor:=) +6. *Save locally (copy down) — the reverse-compile* (read API verified, see *Verified API shape*) — =pearl--reverse-compile-issue-filter= (normalize the view-filter tree, then match against the dimension table), =pearl--issue-filter-representable-p= (structured refusal reasons), the favorited-Linear-view candidate builder, and =pearl-save-linear-view-locally= (fetch =filterData= for the chosen view, reverse-compile, save the fork through =pearl--save-local-view=), keymap + transient slot. Round-trip (including =:open t= and the favorite-derived =:open= shapes), normalize-from-real-view-filter, the open-predicate-=nin=-vs-generic-=nin= split, and refusal tests are the load-bearing coverage. (=feat:=) +7. *README + package-summary* — document filter / local view / Linear view, the git-style lifecycle, the publish / save-locally directions and the representability boundary, and the full create/edit/run/publish/save-locally/delete surface. No "query" in user-facing text. (=docs:=) + +* Implementation tasks (drop-in for todo.org) + +#+begin_src org +,** TODO [#B] local/Linear views — direct rename, no aliases :feature: +Rename the saved-query defcustom and existing commands directly to the local/Linear view vocabulary. No obsolete aliases; remove the old =q= saved-query bindings; update Craig's config in the same change. Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 1). + +,** TODO [#B] local/Linear views — create and edit :feature: +Add =pearl-create-local-view=, =pearl-edit-local-view=, the metadata-preserving =pearl--save-local-view= writer, local Replace/Rename/Cancel collision handling, account stamping/preservation, and shared builder internals with =pearl-list-issues-filtered= (save offer reworded to "save this local view?"). Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 2). + +,** TODO [#B] local/Linear views — keymap and transient :feature: +Expose the view noun under clean product vocabulary (local view / Linear view / filter / publish / save locally) in the keymap and transient. No "query" in user-facing labels. View-operation labels avoid "source"; the unified picker =pearl-pick-source= keeps "source" deliberately (it spans views, filters, and browser-only favorites). Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 3). + +,** TODO [#B] local/Linear views — picker labels + cross-store dedup :feature: +Reword the picker candidates and scope labels to =[local]= / =[local → Linear:SCOPE]= / =[linear]=, and implement the cross-store rule: dedupe same-=CustomView=-id tracked-local-plus-favorite to the local entry; show same-name different-id local and Linear views both. Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 4). + +,** TODO [#B] local/Linear views — publish local view :feature: +Present the existing publish/sync path as =pearl-publish-local-view=, preserving first-publish create and re-publish update-by-id behavior. Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 5). + +,** TODO [#B] local/Linear views — save Linear view locally :feature: +Implement normalize-then-match reverse compilation for readable =CustomView.filterData=, structured refusal reasons, favorited-Linear-view candidates, and forked local-view persistence through =pearl--save-local-view=, as =pearl-save-linear-view-locally=. Reverse-compile contract per the dimension table: distinct =:state= vs =:state-type=, =:open t= mapped from the exact open-state =nin= (generic =nin= refuses), priority canonicalized to numeric 0..4, singular id/scalar keys accept only =eq= or one-element =in=, plural keys (=:labels=, =:state-type=) explicitly whitelisted, and multi-value =in= on singular keys refuses. Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 6). + +,** TODO [#B] local/Linear views — docs :feature: +Update README and package summary with filter / local view / Linear view vocabulary, the publish / save-locally lifecycle, command surface, and representability boundary. No "query" in user-facing text. Spec: [[file:docs/local-and-linear-views-spec.org]] (Implementation phases, phase 7). + +,** TODO [#B] local/Linear views — naming and behavior test surface :test: +Unit: direct renames without aliases, absence of user-facing "query" vocabulary (=pearl-pick-source= explicitly allowed, not banned), metadata-preserving edit, local collision policy, account ownership guard for run/edit/publish/publish-current/delete (delete makes no Linear call on mismatch), cross-store duplicate display/dedupe, tracked-local run/refresh dispatch (=pearl-run-local-view= runs the local filter, not =pearl--query-view-async=), picker labels, reverse-compile round trips and refusals (per-dimension, including =:state= vs =:state-type= with multi-type =in=, =:open t= exact-predicate success and generic-=nin= refusal, priority canonicalization and out-of-range refusal, singular multi-value =in= refusal for =assignee.id=/=assignee.email=/=project.id=/=cycle.id=/=team.key=/=label id=, and the plural =:labels= / =:state-type= exceptions), normalizer fixtures, structured refusals. Integration: copy-down candidate fixtures, stale ids, missing/nil =filterData=, GraphQL errors, publish update-by-id regression, delete does not call Linear on account mismatch, refresh re-runs local filter for a local-view buffer vs server-side for a native Linear-view buffer. E2e / manual-verify: create -> edit -> run local -> publish -> re-publish update -> save Linear view locally -> refuse unrepresentable view -> delete local/tracked views. Spec: [[file:docs/local-and-linear-views-spec.org]] (Acceptance criteria). +#+end_src * Out of scope (vNext) +- *All-Custom-Views copy-down* — enumerating every Custom View (not just favorited ones) via a paged =filterData= query, with its own cache path. v1 copies down favorited views only; this is the part that needs pagination. +- *Richer local authoring model* — OR-logic, nested =and=/=or=, and unmodeled dimensions (label =parent=, due date, estimate, creator). Each one added shrinks copy-down's refused set. Its own spec. +- *Broader negative operators* — generic =nin= / =neq= / =null= beyond Pearl's recognized =:open t= predicate. These need authoring-UI semantics (how a user builds a "not X" filter), not just parser support, so they stay vNext; v1 represents only the exact open-state predicate. +- *Plural authoring keys* — =:assignee-ids=, =:assignees= (emails), =:project-ids=, =:cycle-ids=, =:team-keys=, and =:label-ids= — would let multi-value =in= Linear views ("assignee in A or B", "project in P1 or P2", multi-id labels) copy down instead of refusing. Each needs builder support for selecting several values; v1 keeps the singular keys and refuses the multi-value cases. +- *Richer local filter editor* — exposing priority symbols, negative operators, and broader state logic as first-class authoring choices (rather than the canonical numeric priority and AND-only model v1 ships). +- *Unpublished-changes indicator* — a label like =[local → Linear:Engineering, unpublished]= when a tracked local view's local filter differs from its last-published Linear mirror, so the divergence window after edit (before publish) is visible. +- *Remote-drift detection before copy-up* — warn when the tracked Linear view changed on Linear's side before copy-up overwrites it (today copy-up updates in place unconditionally). - Project / initiative scope on publish / copy-up (still team/personal only, per =saved-query-sync-spec.org=). -- OR-logic and the richer =IssueFilter= constructs in local views (operators, unmodeled dimensions). Expanding the authoring model is its own spec; until then, copy-down refuses the Linear views that use them rather than copying them lossily. Growing the authoring model shrinks the refused set. -- An opaque, run-only copy-down for non-representable Linear views (rejected in favor of refuse-don't-guess; see Open question 3). - Syncing =:sort= / =:order= to Linear. +- A cache inspection / clear path for copied-down view metadata, if view enumeration caching grows. + +(An opaque, run-only copy-down for non-representable views was considered and rejected — refuse-don't-guess, Agreed decision 8.) + +* Review dispositions + +The Codex review (=Needs research=) was strong and code-grounded; most findings were accepted as written. Only the modified ones and the resolved blocker are listed here; everything else — HP2 (metadata-preserving edit), HP4 (local collision policy), MP1 (primary vs legacy reverse shapes), MP2 (account behavior), MP3 (observability), and the architecture/test-strategy observations — was accepted as written and woven into the body. + +- *HP1 (verify =CustomView.filterData= read shape) — accepted, then resolved by probe, with a correction.* A live probe (2026-06-01) confirmed =filterData= is readable (=NON_NULL JSONObject=) but showed its shape is *not* the =IssueFilter= input Pearl emits — it is Linear's view-filter =and=/=or= tree (top-level =and=, single-branch =or= wrappers, =in=[single]= scalars). The reverse-compile was redesigned from a one-to-one inversion into normalize-then-match. This was the highest-value finding: it changed the design before implementation rather than at integration. +- *HP3 (close the open questions) — accepted.* All eight dispositioned with Craig and moved to *Agreed decisions*; none remain open. +- *HP5 (candidate source + pagination) — modified (scope).* The review offered all-Custom-Views (paged) or favorited-only. Chose favorited Linear views for v1: it is already bounded, reuses the existing favorites enumeration, and removes the pagination/cache complexity entirely. All-Custom-Views enumeration deferred to vNext, where the pagination it requires belongs. +- *Rubric — modified (scope of the hold).* The review held the whole spec at =Needs research= on HP1. Narrowed: only copy-down depended on the read-API question, and the probe cleared it, so the spec is =Ready= end to end rather than gated. Phases 1-5 never depended on the probe. + +Everything else in the first review was accepted as written. + +** Second review (naming contract, =Not ready=) + +A second Codex pass, grounded in Craig's clarification that Pearl has no users, held the spec at =Not ready= on the naming contract. Folded in: + +- *HP1 (drop backward-compat / aliases) — accepted.* With no users, the obsolete aliases protected nobody and kept "query" alive in completion, tests, and docs. Renames are now direct; the rename map's alias column is gone; the Migration, acceptance, and phase text drop the alias requirement; Craig's own config is the only thing updated. +- *HP2 (eliminate user-facing "query") — accepted.* "query" survives only in internal GraphQL helpers and historical text. Added a naming-regression acceptance criterion. +- *HP3 (intent-first command names) — accepted, with one modify.* Copy-up's command is =pearl-publish-local-view= (not =pearl-copy-view-to-linear=); copy-down's is =pearl-save-linear-view-locally=. *Modify:* the review wanted =pearl-pick-source= replaced with a view-oriented name; kept =pearl-pick-source= because it runs favorites, by-dimension filters, and browser-only favorites too — "source" is accurate and is the word =issue-sources-spec= established. Renaming it "run view" would misname it. +- *HP4 (keymap/transient/docs/tests around clean vocab) — accepted.* Since the commands are publish / save-locally, not "copy", they moved out of the =C-; L y= clipboard group into the fetch group; =y= keeps its clipboard meaning. +- *HP5 (cross-store duplicate display) — accepted.* Same-=CustomView=-id entries dedupe to the local tracked view; same-name different-id entries both show with qualified labels. New subsection + decision + tests. +- *MP1/MP2/MP3 — accepted.* Keep "filter"; =CustomView= at the API boundary only; stated the v1 performance bounds. + +The naming pivot reversed three first-pass decisions (alias-based rename, =pearl-copy-view-to-linear= primary, copy ops under =C-; L y=). Craig ruled on each before they were folded. + +** Third review (run/refresh semantics, =Not ready=) + +A third Codex pass confirmed the naming contract and surfaced one blocking behavioral ambiguity the response passes had left: a published local view has two filters (the local authoring plist and the last-published Linear mirror), and the spec never said which one runs. + +- *HP1 (run/refresh semantics) — accepted.* A sharp catch of a contradiction this responder introduced: the model says "local is the source of truth" and the dedup rule said selecting a tracked entry "lands you on the thing you can edit," but the current code dispatches synced entries through the Linear server-side branch. Resolved per the model: =pearl-run-local-view= and a deduped =pearl-pick-source= run the *local* filter even when tracked; =pearl-run-linear-view= runs the server-side mirror; local-view buffers are =:type filter= sources that refresh locally. This is the direct consequence of an already-agreed decision, so it was folded as the model-consistent resolution (new *Run and refresh semantics* subsection, decision 16) — flagged to Craig as the one behavior point to override. +- *MP1 (vocabulary contradiction) — accepted.* The task/test wording banned user-facing "source" while the spec deliberately keeps =pearl-pick-source=. Narrowed: ban "query"; view-operation labels avoid "source"; the unified picker keeps it. +- *MP2 (unpublished-changes indicator) — accepted to vNext.* +- Architecture/test observations (separate source descriptors for local vs Linear; dispatch and refresh tests) — accepted into the body. + +** Fourth review (=:open= / =nin= contradiction, =Not ready=) + +A fourth Codex pass found the last internal contradiction, again one this responder left: the copy-down acceptance criterion promised every =pearl--build-issue-filter= shape round-trips, but the refusal list blanket-refused =nin=, and Pearl's very common =:open t= compiles to =state.type.nin=["completed","canceled","duplicate"]=. Both can't hold. + +- *HP1 (=:open t= / =nin=) — accepted in full.* Verified against =pearl--compile-state-filter= (=:open t= → =state.type.nin= the open-state types; =:state= / =:state-type= take precedence) and the favorite-derived =(:project ID :open t)= shapes. Added =:open= as a first-class reverse-compile row matching exactly that =state.type.nin= set (order-insensitive); qualified the refusal so generic =nin= still refuses but the open predicate maps to =:open t=; added the precedence note, the round-trip and refusal acceptance criteria, the phase-6 fixture, and a vNext line for broader negation (which needs authoring UI, not just a parser). +- The review's open question — exact open predicate only, or broader =nin=? — answered: exact predicate only in v1, broader negation vNext. + +No other findings; the review confirmed naming, ownership, run/refresh, persistence, collision, and performance are resolved. + +** Fifth review (reverse-compile table precision, =Not ready=) + +A fifth Codex pass found three more table imprecisions where the "identical plist" round-trip promise outran Pearl's actual DSL. All three accepted in full (verified against =pearl--compile-state-filter=, =pearl--compile-priority=, and the label keys): + +- *HP1 (=:state= vs =:state-type=) — accepted.* The single =:state= table row conflated two distinct keys: =:state= → =state.name.eq=, =:state-type= → =state.type.in=. =state.type.in= can't round-trip to =:state=. Split into two rows; the reverse emits the matching key; precedence over =:open= preserved. +- *HP2 (=:priority= canonical form) — accepted.* =pearl--compile-priority= maps both =(:priority high)= and =(:priority 2)= to =priority.eq=2=, so the symbol-vs-int choice isn't recoverable. Defined canonical numeric output (0..4) with a documented-canonicalization carve-out in the round-trip promise; out-of-range refuses. +- *HP3 (multi-id labels) — accepted.* =:label-id= is singular and there's no =:label-ids= key, so single =labels.some.id= emits =:label-id= and multi-id =labels.some.id.in= refuses in v1 (plural key is vNext). =:labels= names stay representable. +- *MP1 (stale task block) — accepted.* Refreshed the copy-down and test-surface drop-in tasks to name the =:open=, state/state-type, priority, and label-id fixtures. + +The open question is answered by the minimal v1 rules the review proposed (state.type.in → =:state-type=; priority → numeric; multi-id labels refuse). No findings remain open. + +** Sixth review (singular multi-value =in= + account guard scope, =Not ready=) + +A sixth Codex pass generalized two patterns the spec had only half-stated. Both accepted in full: + +- *HP1 (multi-value =in= on singular keys) — accepted.* Last round fixed multi-id labels; the same gap applied to =:assignee-id=, legacy =:assignee= email, =:project=, =:cycle=, and =:team=. Replaced the label-specific rule with a general one: a singular key accepts only =eq= or a one-element =in=; multi-value =in= refuses unless the key is genuinely plural (=:labels=, =:state-type=). Generalized the boundary bullet, acceptance criteria, and tests accordingly. +- *HP2 (account guard scope) — accepted.* The spec stamped =:account= and guarded edit/copy, but not run, publish, publish-current, or delete. Verified the code gap (=pearl-sync-saved-query-to-linear= and =pearl-delete-saved-query= don't check the entry's =:account= today, only the buffer context). Defined one =pearl--require-local-view-account= guard over every read/mutate operation, with delete and publish refusing *before* any Linear call on mismatch; legacy/untagged entries stay unguarded. + +vNext gained the plural authoring keys (=:assignee-ids=, =:project-ids=, =:cycle-ids=, =:team-keys=, =:label-ids=). No findings remain open. + +* Review and iteration history + +** 2026-06-01 Mon @ 15:03:50 -0500 — Codex — reviewer + +- *What changed or was recommended:* Reviewed the spec against the implementation and related specs. Wrote a sibling review with a =Needs research= rubric, blocking findings for unverified =CustomView.filterData= read shape, metadata-preserving edit semantics, unresolved implementation-changing open questions, local name collision behavior, and copy-down candidate boundaries. +- *Why:* Copy-down and edit add new ownership/persistence paths beyond the shipped one-way sync. Without these decisions, implementation would have to invent API assumptions and data-loss behavior mid-stream. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 15:24:03 -0500 — Claude Code (pearl) — responder + +- *What changed:* Dispositioned every Codex finding. Accepted HP2/HP4/MP1/MP2/MP3 and the architecture/test observations into the body (metadata-preserving =pearl--save-local-view=, local Replace/Rename/Cancel collision policy, primary-vs-legacy reverse shapes, account stamping, copy-down progress messages, pure-helper decomposition). Modified HP5 (favorited-only candidate source for v1) and the rubric scope. Ran a live API probe to clear HP1 and corrected the =filterData= shape assumption, redesigning the reverse-compile as normalize-then-match. Closed all eight open questions into *Agreed decisions* with Craig. Status raised Draft → Ready. +- *Why:* Copy-down and edit are the first operations that reconstruct or preserve local authoring state, so they needed sharper contracts than the one-way publish path. The probe was the load-bearing step: the read shape was an assumption, and it was wrong in a way that mattered. +- *Artifacts:* Live probe against =https://api.linear.app/graphql= (=customViews{ nodes{ id name filterData } }= + =CustomView= introspection); seven real Custom Views inspected for filter shape. Review file deleted on completion. + +** 2026-06-01 Mon @ 15:30:53 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow after the response pass. Confirmed the prior blockers are resolved, assigned =Ready=, and added the required =Implementation tasks (drop-in for todo.org)= block from the phase plan. +- *Why:* The updated spec now has verified copy-down API shape, closed v1 decisions, explicit ownership/persistence semantics, safe local collision behavior, account behavior, bounded copy-down scope, acceptance criteria, and a phaseable implementation plan. +- *Artifacts:* No sibling review file needed; the spec is implementation-ready. + +** 2026-06-01 Mon @ 15:35:43 -0500 — Codex — reviewer + +- *What changed or was recommended:* Reopened review based on Craig's naming and collision/performance feedback. Wrote a new sibling review holding the spec at =Not ready= until it removes the no-longer-needed backward-compatibility layer, eliminates user-facing "query", promotes intent-first public command names (publish / save locally / run view), defines cross-store duplicate behavior for local-vs-Linear view names and same =CustomView= ids, states the v1 performance bounds, and updates phases/tests around the clean vocabulary. +- *Why:* Pearl has no users, so preserving old saved-query names and aliases creates confusion without protecting anyone. The spec's public API should match Linear users' vocabulary directly: filter for conditions, local view / Linear view for saved runnable things. The unified picker also needs deterministic behavior when the same title or same Linear id appears through both local and Linear stores. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 15:43:09 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the naming-contract review. Dropped the obsolete-alias / backward-compat layer entirely (direct renames, old =q= bindings removed, Craig's config updated in-change). Eliminated user-facing "query" and added a naming-regression acceptance criterion. Made command names intent-first: =pearl-publish-local-view= for copy-up, =pearl-save-linear-view-locally= for copy-down; moved them out of the =C-; L y= clipboard group into the fetch group. Added the cross-store duplicate-display policy (HP5) and the v1 performance-bounds note (MP3). Kept =pearl-pick-source= against HP3's rename suggestion (it runs more than views). Reconciled the three reversed first-pass decisions with Craig before folding. Rewrote the Agreed decisions (now 15), acceptance criteria, phases, and the drop-in task block around the clean vocabulary. Status restored to =Ready=. +- *Why:* With no users to protect, the alias layer was pure cost — it kept "query" alive in exactly the surfaces the rename meant to clean. Intent-first command names are the product's UX; symmetry ("copy up/copy down") matters less than the word a Linear user searches for. +- *Artifacts:* Review file deleted on completion. + +** 2026-06-01 Mon @ 15:59:16 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow against the updated spec. Wrote a new sibling review holding the spec at =Not ready= until tracked local view run/refresh semantics are explicit, especially whether a published local view with unpublished local edits runs the local filter or the linked Linear view. Also recommended aligning the "no source language" test/task wording with the decision to keep =pearl-pick-source=. +- *Why:* The spec now says the local view is the editable source of truth, while the current implementation runs synced entries through the Linear view branch. That answer changes picker dispatch, source descriptors, refresh behavior, and tests. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:03:12 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the run/refresh review. Added a *Run and refresh semantics* subsection and decision 16: the local view is the runtime source of truth, so =pearl-run-local-view= and a deduped =pearl-pick-source= run the local filter even for a tracked entry, =pearl-run-linear-view= runs the server-side mirror, and local-view buffers are =:type filter= sources that refresh locally. Tightened the cross-store dedup line to say the deduped entry runs local. Fixed the vocabulary contradiction (MP1): banned "query", not "source", so the kept =pearl-pick-source= is consistent. Added the unpublished-changes indicator to vNext (MP2). Updated acceptance criteria, phase 4, and the test surface with the dispatch/refresh semantics. +- *Why:* The responder had introduced the contradiction — the model claims local is the source of truth, but the inherited code runs tracked entries server-side. Resolving it toward local-runs-local is the only reading consistent with the model already agreed; it changes current behavior deliberately. Flagged to Craig as the one behavior point to confirm or override. +- *Artifacts:* Review file deleted on completion. + +** 2026-06-01 Mon @ 16:11:25 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow against the latest spec and wrote a focused =Not ready= review. The remaining blocker is the copy-down reverse-compile contract for =:open t=: current Pearl compiles =:open t= to =state.type.nin=["completed","canceled","duplicate"]=, while the spec both promises every Pearl-authored filter shape round-trips and says =nin= is unsupported/refused. Recommended adding an explicit =:open t= representability rule for that exact predicate, while continuing to refuse generic =nin=. +- *Why:* =:open t= is part of Pearl's current authoring model and appears in existing favorite-derived filters. Without this rule, implementation has to choose between rejecting common representable views or violating the round-trip acceptance criterion. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:21:51 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the =:open=/=nin= fix. Verified against =pearl--compile-state-filter= and the favorite-derived =:open t= shapes, then added =:open= as a first-class reverse-compile row (matching exactly =state.type.nin= of the open-state types, order-insensitive), qualified the refusal list so generic =nin= still refuses but the open predicate maps to =:open t=, added the precedence note, the =:open= round-trip and generic-=nin=-refusal acceptance criteria, the phase-6 fixture, and a vNext line for broader negation. Resolved the review's open question: exact open predicate only in v1. +- *Why:* The acceptance criterion (every Pearl shape round-trips) and the blanket =nin= refusal couldn't both hold, since Pearl's most common predicate compiles to =nin=. Representing exactly =:open t= and nothing broader keeps the round-trip true without opening general negation, which needs authoring UI. +- *Artifacts:* Read =pearl--compile-state-filter= / =pearl--filter-keys= to confirm the compiled shape. Review file deleted on completion. + +** 2026-06-01 Mon @ 16:26:07 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran a thorough final spec-review pass and wrote a sibling =Not ready= review focused on remaining reverse-compile contradictions. Findings: split =:state= from =:state-type= in the match table, define canonical numeric =:priority= reverse output because symbols and integers compile to the same =priority.eq= value, and specify that =labels.some.id.in= with more than one id refuses unless a plural id authoring key is added. Also noted the drop-in task block is stale after the =:open= fix. +- *Why:* Copy-down must produce editable local authoring plists. If the reverse compiler's output shape is not specified for these existing =pearl--build-issue-filter= forms, implementation has to invent product behavior and tests cannot assert a stable contract. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:33:11 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded in the three reverse-compile table fixes. Verified against =pearl--compile-state-filter=, =pearl--compile-priority=, and the label keys, then: split the =:state= row into =:state= (=state.name.eq=) and =:state-type= (=state.type.in=); defined canonical numeric =:priority= (0..4, with the documented-canonicalization carve-out in the round-trip promise, out-of-range refuses); restricted =:label-id= to a single id and added multi-id refusal. Updated the boundary refusal list, the acceptance criteria (per-key round-trip + refusal cases), the phase-6 contract, the drop-in task block (MP1), and vNext (plural =:label-ids=, richer editor). +- *Why:* The "identical plist" promise was stronger than Pearl's DSL allows: =state.type.in= maps to =:state-type= not =:state=, priority symbols and integers collapse to one number, and multi-id label filters have no authoring key. Naming the exact reverse output (and refusing what has no representation) keeps copy-down's editable-result guarantee honest. +- *Artifacts:* Read =pearl--compile-priority= and confirmed no =:label-ids= key. Review file deleted on completion. + +** 2026-06-01 Mon @ 16:42:12 -0500 — Codex — reviewer + +- *What changed or was recommended:* Restarted the requested two-clean-pass review loop; pass 1 was not clean. Wrote a sibling =Not ready= review with two blockers: add a general singular-vs-plural =in= rule so multi-value =in= on singular id fields (=assignee.id=, =project.id=, =cycle.id=, and singular label id) refuses unless the target key is explicitly plural; and expand account ownership guards from edit/copy to every local-view operation that reads or mutates an entry, especially publish and delete. +- *Why:* Copy-down must not silently narrow multi-value Linear filters to singular local keys, and account-tagged local views must not be run, published, or remotely deleted under the wrong Linear account. Both decisions affect implementation and tests. +- *Artifacts:* =local-and-linear-views-spec-review.org= (consumed and deleted by the response pass below). + +** 2026-06-01 Mon @ 16:58:24 -0500 — Claude Code (pearl) — responder + +- *What changed:* Folded both blockers. Generalized the multi-id-label rule into a singular-vs-plural =in= rule covering =:assignee-id=, =:assignee= email, =:project=, =:cycle=, =:team=, and =:label-id= (multi-value =in= refuses; only =:labels= and =:state-type= take multiple), updating the reverse-compile section, boundary list, acceptance criteria, and tests. Expanded the account guard from edit/copy to one =pearl--require-local-view-account= over run/edit/publish/publish-current/delete/save-over, with delete and publish refusing before any Linear call on mismatch; verified the current code only guards buffer context, not the entry tag. Refreshed the drop-in task block and broadened the vNext plural-keys item. +- *Why:* Both were patterns the spec stated for one case (labels; edit/copy) but not the general one. Naming the full rule keeps copy-down from silently narrowing a view and keeps a remote delete/publish from firing against the wrong workspace. +- *Artifacts:* Confirmed =pearl-sync-saved-query-to-linear= / =pearl-delete-saved-query= guard only buffer/account context, not the entry's =:account=. Review file deleted on completion. + +** 2026-06-01 Mon @ 17:02:44 -0500 — Codex — reviewer + +- *What changed or was recommended:* Re-ran the spec-review workflow against the latest spec, including two clean contradiction passes after reading the implementation, related specs, acceptance criteria, implementation phases, and task block. Assigned =Ready= with no blocking findings. Corrected the stale status count from two to six incorporated review rounds. +- *Why:* The prior blockers are now resolved in the spec: user-facing vocabulary is Linear-aligned, local-vs-Linear run/refresh ownership is explicit, copy-down representability and refusal rules are precise, account ownership guards cover read and mutate operations, performance bounds are stated, and the test surface covers the risky behavior. +- *Artifacts:* No sibling review file needed; no project-local =todo.org= exists, and the spec already contains the drop-in implementation task block. |
