aboutsummaryrefslogtreecommitdiff
path: root/docs/saved-query-sync-spec.org
blob: a17239e872c5587a134f9a03a69b5516d9c781e5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
#+TITLE: Spec: sync local saved queries to Linear views
#+AUTHOR: Craig Jennings & Claude
#+DATE: 2026-05-28

* Status

*Done.* Shipped 2026-05-28 across commits 60a026b (Phase 2: pearl-sync-saved-query-to-linear + transient entry), c8b9ad1 (Phase 3: extend pearl-delete-saved-query with delete-on-Linear prompt), fd94237 (Phase 4: pearl-pick-source distinguishes [saved] / [saved → scope] / [view] + :linear-view-url plumbing), and 0975e7d (Phase 5: pearl-publish-current-source convenience wrapper). README sync-up section and this status flip in the Phase 6 docs commit. Sprint review with Craig completed 2026-05-28; all six open questions dispositioned (Q1, Q2, Q3, Q4, Q5, Q6, plus probe-surfaced Q7 absorbed by the Q2 collapse). Triggered by the conversation on 2026-05-28 after =pearl-delete-saved-query= shipped ("we'll need functionality that allows us to delete these, and also syncs non-local versions back to Linear"). Sibling spec to =docs/issue-sources-spec.org=, which established favorites + the unified picker but kept local saved queries as a local-only Customize variable.

Live-API probe completed 2026-05-28 (see § Review dispositions). Mutations confirmed: =customViewCreate=, =customViewUpdate=, =customViewDelete= (no archive). =CustomViewCreateInput.filterData= is type =IssueFilter= -- the same type pearl's existing compiler produces, so the mapping is a pass-through. Implementation phase 1 trivializes accordingly.

* Problem

Pearl's local =pearl-saved-queries= are useful but isolated. A query authored in pearl lives in one user's Emacs configuration. The team can't see it, the Linear web UI can't run it, and switching machines means losing the work. Linear's own answer to "named, reusable query" is *Custom Views*, which are first-class: shareable, scoped to a team or to the viewer, archivable, and visible alongside the user's Linear sidebar favorites.

Pearl already reads custom views (=pearl-run-view=, favorites dispatch). What it doesn't do is write them. So a saved query the user grew through pearl's ad-hoc builder is stuck local, even when it would be obviously useful as a team-visible view. Closing that gap is "sync the local one up so it becomes a view," not a wholesale move to Linear-side storage.

The save-vs-run asymmetry today: a saved query is created in pearl, persists locally, runs from pearl. A view is created in Linear's web UI, persists on Linear, runs from anywhere including pearl. Sync-up bridges those two paths so a saved query authored in pearl can graduate to a view without the user re-creating it in the web UI.

* Non-goals

- *Two-way sync.* Read-down already works (pearl reads views). Write-down (re-pull view changes into a local saved query) is not v1. A synced query treats Linear as the source of truth from sync-up onward; if Craig edits the view in Linear, pearl sees the new shape on the next refresh.
- *Local saved-query elimination.* Local saved queries stay supported as the lightweight, no-API-cost path. Sync-up is opt-in per query, not automatic.
- *Cross-account sharing.* Multi-account support is a separate spec ([[file:multi-account-spec.org][multi-account-spec.org]]). v1 sync targets the currently-active account.
- *Editing the *view's* filter through pearl-run-view's rendered buffer.* The buffer is a view of the view; editing the filter happens by editing the local saved query and re-syncing.
- *Inverse migration (delete a view → re-create as local-only saved query).* Out of scope; if the user wants local, they author local.

* Current state (what exists, what's missing)

** Exists

- =pearl-saved-queries= defcustom (alist of NAME . (=:filter= PLIST [=:sort= S] [=:order= O])).
- =pearl-run-saved-query= reads the entry and renders.
- =pearl-delete-saved-query= ([[file:../pearl.el::3467][pearl.el:3467]]) removes it locally.
- =pearl-list-issues-filtered= builds and optionally saves a query (=pearl--save-query=).
- =pearl-run-view= ([[file:../pearl.el::4696][pearl.el:4696]]) renders a Linear custom view by id.
- =pearl--query-view-async= + =pearl-view-issues-query= ([[file:../pearl.el::1077][pearl.el:1077]]) issue the =customView(id:)= read.
- =pearl-pick-source= lists favorites + saved queries side by side; favorites with kind =view= route to =pearl-run-view=.
- Filter compiler =pearl--build-issue-filter= produces the Linear =IssueFilter= JSON pearl uses against =issues(filter:)=.

** Missing

- Any mutation against Linear's =customViewCreate=, =customViewUpdate=, =customViewArchive= (or whatever Linear's current mutation names are; see Open questions §1).
- A mapping from pearl's authoring filter plist to the JSON shape Linear's =customView.filterData= accepts.
- A backlink: =pearl-saved-queries= entries carry =:filter= today and nothing else identifying a remote counterpart.
- A user-facing command to push a saved query up, and the surrounding UX (team selection, name conflict handling, archive-on-delete prompt).
- Picker disambiguation for synced vs local-only.

* Proposed design

** Schema extension on pearl-saved-queries

Each saved-query entry becomes:

#+begin_src elisp
("name" :filter PLIST
        :sort S       ; optional, existing
        :order O      ; optional, existing
        :linear-view-id "UUID"            ; NEW: set after a successful sync
        :linear-view-team-id "UUID"       ; NEW: optional, the team scope (nil = no team scope)
        :linear-view-shared t-or-nil      ; NEW: shared=t means team/everyone can see; nil means viewer-only
        :linear-view-synced-at "2026-05-28T01:30:00Z"  ; NEW: provenance
        )
#+end_src

The =:linear-view-id= key is the synced-vs-local discriminator. Absent = local-only. Present = backed by a Linear view; refresh/runs go through =customView(id:)= rather than =issues(filter:)=.

The =:linear-view-team-id= records the team scope at sync time, so a later re-sync can re-push to the right team. A view with no team scope leaves it nil.

The =:linear-view-shared= records the visibility at sync time. =nil= or absent = personal (only the viewer sees it); =t= = visible to the team or workspace per the team scope. The Linear API exposes this as a separate =shared: Boolean= input alongside the scope ids.

The =:linear-view-synced-at= is provenance only; not used to gate behavior in v1, but available for "you synced this 3 weeks ago, the local filter has changed since" diagnostics if we want them later.

*Project and initiative scopes (=projectId=, =initiativeId= on the Linear input) are not surfaced in v1.* The API supports them, but the typical sync target is a team view. Adding the prompts is a vNext layer.

** User-facing command surface

Three commands, plus updates to two existing commands.

*** New: =pearl-sync-saved-query-to-linear=

Promotes a local saved query to a Linear custom view, or updates the linked view if already synced.

Interactive flow:
1. Prompt for which saved query (completing-read over =pearl-saved-queries= names, =[ Cancel. ]= sentinel).
2. If the entry has no =:linear-view-id= (first-time sync): one enriched scope-and-visibility prompt. Each candidate spells out the complete end-state so the user picks a destination, not two orthogonal dimensions to mentally assemble:
   #+begin_example
   Where does this view live?
     [ Team: Engineering, visible to the team ]         <- default when :team Engineering in filter
     [ Personal, only I see it ]
     [ Team: Engineering, only I see it ]
     [ Team: Marketing, visible to the team ]
     [ Team: Marketing, only I see it ]
     ...
     [ Cancel. ]
   #+end_example
   The candidate list is built from the user's teams. For each team, two rows (=visible to the team= and =only I see it=). Plus one =[ Personal, only I see it ]= row. The meaningless "personal scope + shared" combination is absent (no team to share with). Default (topmost) is =[ Team: <X>, visible to the team ]= when the filter has =:team X=, else =[ Personal, only I see it ]=. Most-common-on-top per the pattern in =pearl--with-sentinel= callers. Cancel via =[ Cancel. ]=.
3. If the chosen name already exists as a view in the chosen scope: prompt =Replace? Rename? Cancel?=. =Replace= updates the existing view's =filterData= (and =shared= / =teamId= if they changed). =Rename= prompts for a different name. =Cancel= aborts the sync.
4. Pass the entry's =:filter= plist through =pearl--build-issue-filter= -- the existing compiler already produces an =IssueFilter=, which is exactly the type =customView.filterData= accepts. No new mapping helper needed; the existing compiler IS the mapping.
5. Call =customViewCreate= (first-time) or =customViewUpdate= (already synced) with the team id, shared flag, and filterData derived from the picked end-state row.
6. On success: store the returned id, team-id, and shared into the entry, persist via =customize-save-variable=, message "Synced NAME to Linear as <end-state label>".
7. On failure: leave the entry untouched, message the API error.

Bind under =C-; L f S= (capital S for sync, parallel to =s= for save). Also expose in the transient under Fetch as ="S" "sync saved query to Linear"=.

*** New: =pearl-publish-current-source=

Convenience wrapper: read the active buffer's =#+LINEAR-SOURCE=, if it's a =:type filter= with a name (i.e. the buffer is rendering a local saved query), sync that query up. Saves a step when the user is reading a query and decides "this should be a view."

Skip if the source is a view (already on Linear) or a transient filter (no name to sync under).

Bind: =C-; L f P= (capital P for Publish, parallel to lowercase =f p= = by-project; lives next to =f S= = sync-saved-query in the Fetch/source-ops sub-group). The transient entry letter is implementation-time detail — propose =U= ("upload") in Fetch since =P= is already pick-source in the transient's flat namespace. The fetch sub-group is increasingly "source operations" (run + sync + publish) rather than pure fetch; reshaping the prefix is the [#B] keybinding-shape review task, not this spec's job.

*** Extended: =pearl-delete-saved-query=

If the chosen entry has a =:linear-view-id=, add a second prompt after the existing "Delete saved query X?" confirmation:

="Also delete the linked Linear view (\"NAME\")? (yes/no)"=

- =yes= → call =customViewDelete= with the id, then delete locally. If the delete call fails, surface the error and ask whether to delete locally anyway.
- =no= → unlink only (drop the =:linear-view-id= / =:linear-view-team-id= / =:linear-view-shared= keys), then delete locally. The view stays on Linear.

For local-only entries (no =:linear-view-id=), behavior is unchanged.

Note: Linear's API exposes =customViewDelete= but not =customViewArchive=. Custom views aren't an archive-able entity in the API surface, so the choice is delete-or-unlink, not archive-or-unlink. =customViewDelete='s recoverability is whatever Linear's backend provides for the entity (likely trash with a recovery window per the standard Linear pattern).

*** Extended: =pearl-pick-source= candidate label

Today the picker shows local saved queries as =[saved] Name=. Add a third label format for synced ones that carries the scope, so the user reads provenance + destination in one scan:

| State                                    | Label format                       |
|------------------------------------------+------------------------------------|
| Local-only saved query                   | =[saved] Name=                     |
| Synced saved query (team-scoped)         | =[saved → Engineering] Name=       |
| Synced saved query (personal)            | =[saved → Personal] Name=          |
| View favorite                            | =[view] Name=                      |

The arrow reads "where it lives now." The team name (or =Personal=) is derived from the entry's =:linear-view-team-id= via =pearl--team-name-by-id=, with =Personal= as the fallback when =:linear-view-team-id= is nil. The shared flag is not surfaced in the label -- at picker time the user is *running* the view, not editing its visibility, so the destination scope is the load-bearing fact. (Shared is visible at sync time and on the next sync prompt.)

This follows the same enrichment pattern as the Q2 collapsed scope prompt: each label conveys both kind and the key state metadata. The cost is wider rows for synced entries; the win is no second action needed to see where the view lives.

Dispatch logic: a =[saved → ...]= entry uses =pearl-run-view= against the stored =:linear-view-id=, so refresh and pagination respect any Linear-side filter drift. A =[saved]= entry (local-only) still uses the local filter.

** Filter mapping: pearl plist → Linear customView.filterData

*Resolved by 2026-05-28 live-API probe:* =CustomViewCreateInput.filterData= is type =IssueFilter= -- the exact same type pearl's existing =pearl--build-issue-filter= produces for the =issues(filter:)= query. The mapping is therefore a pass-through. No new helper is needed; the compiler IS the mapping.

#+begin_src elisp
;; The sync command builds filterData via:
(pearl--build-issue-filter (plist-get entry :filter))
#+end_src

Every dimension pearl currently authors (=:open=, =:state=, =:project=, =:cycle=, =:label-id=, =:labels=, =:assignee=, =:assignee-id=, =:team=) is already accepted by =IssueFilter=. The full mapping table that earlier appeared here collapses to one line.

This is a substantial spec simplification surfaced by the probe. The original Implementation phase 1 (=feat(view-sync): map pearl filter plist to customView filterData JSON= -- pure helper plus unit tests) becomes trivial -- pearl already has that helper -- and the unit tests for it already exist under test-pearl-filter.el. Adjust the phase 1 task: instead of writing a new helper, the sync command consumes the existing one.

*** Sort and order

*Resolved by 2026-05-28 probe:* =CustomViewCreateInput= has no =sortOptions=, =sortField=, =sortBy=, or equivalent input. Linear views appear to manage sort through their own UI rather than through the API surface this spec touches. v1 therefore does not sync =:sort= or =:order=; the synced view sorts however Linear's defaults render it.

This is a v1 cost the user has to accept: a saved query that depends on a specific sort order won't have that order applied when run as the synced view. Surface in the README ("synced views use Linear's default sort"). Sort-sync moves to vNext, contingent on Linear adding a sort input or pearl finding the input through a different mutation surface.

** Conflict handling: name collisions

Linear allows duplicate view names within a team (it disambiguates by id). Pearl is stricter at sync time: prompt =Replace? Rename? Cancel?= on any existing same-name view in the chosen scope. The friction is intentional. Auto-naming around collisions ("View (2)") produces views the user didn't name and has to clean up later. An explicit choice is cheaper.

Replace updates by id (=customViewUpdate=); the existing view's id is preserved, so anyone with that view favorited keeps it. Rename re-prompts. Cancel aborts cleanly.

** Conflict handling: remote drift on update

Sync-update (the user re-syncs a query they've already pushed) overwrites the view's filterData with the local plist. This is one-way push by design: pearl is the editor, Linear is the publishing target. If Craig edited the view in Linear since the last pearl sync, the next pearl sync clobbers those edits with the local plist.

This is acceptable for v1 because the user is explicitly invoking sync-up; the verb is "push my version." If we want a safety prompt later ("the Linear view was edited 3 days ago; overwrite?"), it can layer on top via a remote-=updatedAt= check before the update.

** Account scope

v1 targets the currently-active Linear account (single-account world). Multi-account support ([[file:multi-account-spec.org][multi-account-spec.org]]) adds a per-saved-query =:account= field. Sync-up will need to record the account at sync time so a later re-sync goes to the right workspace; until multi-account ships, the active account is implicit and the field stays nil.

* Files touched

- =pearl.el= — new helpers and commands described above. Estimated ~150 lines plus tests.
- =docs/saved-query-sync-spec.org= — this file.
- =README.org= — new subsection under Sources describing sync-up, the picker label conventions, and the archive-on-delete prompt.
- =tests/test-pearl-saved-query-sync.el= (new) — unit tests for the filter-to-view mapping, the entry-extension shape, the dispatch logic in the picker, and the archive-on-delete branch.

* Migration

None required. Existing =pearl-saved-queries= entries have no =:linear-view-id=, so all dispatch and delete paths fall through to today's behavior. Sync is opt-in per entry.

If an entry's filter shape uses a key the v1 mapping doesn't support, sync-up refuses cleanly ("can't represent X dimension in view filterData") and leaves the entry as local-only.

* Open questions (for Craig)

All questions resolved; see § Review dispositions. Spec status moves to *Ready*.

* Acceptance criteria

1. =pearl-sync-saved-query-to-linear= successfully creates a Linear custom view from a local saved query (one round trip against the live API, verified by reading the view back via =pearl-run-view= and checking the issue list matches).
2. Re-running sync on an already-synced query updates the linked view's filter in place (same view id, new filterData).
3. =pearl-delete-saved-query= on a synced entry offers the archive prompt; =yes= archives, =no= unlinks only, =cancel= aborts both.
4. =pearl-pick-source= shows =[saved↑]= for synced entries, =[saved]= for local-only, =[view]= for view favorites; dispatch routes correctly for each.
5. Name-collision prompt fires when sync-up names a view that already exists in the chosen scope.
6. Filter-shape mapping covers every dimension pearl currently authors; a filter using an unsupported dimension (none exist today, but a future =:priority= could) refuses with a clear error rather than silently dropping the dimension.
7. Sort and order map per the table above.
8. README has the new subsection. The spec status moves to =Done= and the closing commit message links it.

* Implementation phases (commits)

1. *=feat(view-sync): wire pearl--build-issue-filter as the customView.filterData producer=* — confirmed by the 2026-05-28 probe that =filterData= is type =IssueFilter=, so this phase is now a trivial sanity-test: round-trip a few representative filter plists through the existing compiler and assert the output shape matches =CustomViewCreateInput.filterData=. No new helper.
2. *=feat(view-sync): pearl-sync-saved-query-to-linear command + transient entry=* — the user-facing command, team-scope prompt, shared-flag prompt, replace/rename/cancel collision handling, schema-extension on the saved-query entry.
3. *=feat(view-sync): extend pearl-delete-saved-query with archive prompt=* — the archive-or-unlink branch for synced entries.
4. *=feat(view-sync): pearl-pick-source distinguishes [saved] vs [saved↑] vs [view]=* — label and dispatch updates.
5. *=feat(view-sync): pearl-publish-current-source convenience wrapper=* — read =#+LINEAR-SOURCE=, route to sync-up if it's a named saved-query filter.
6. *=docs(view-sync): README Sources section gets the sync-up + picker-label conventions=*.

Each commit is independently reviewable; (1) lands the load-bearing helper, (2-5) layer commands on top, (6) closes the loop.

* Implementation tasks (drop-in for todo.org)

The full list of =todo.org= entries that fully implementing and testing this spec would require. Copy-paste into =todo.org='s =* Pearl Open Work= header once the spec is approved. Each entry is independently shippable per the commit decomposition above. Tags follow [[file:../../rulesets/claude-rules/todo-format.md][todo-format.md]].

#+begin_src org
,** TODO [#B] view-sync: confirm pearl--build-issue-filter output matches customView.filterData   :feature:quick:solo:
Probe confirmed =filterData= is type =IssueFilter= -- pearl already produces it. This phase is a sanity-test commit: round-trip a few representative filter plists through =pearl--build-issue-filter=, assert each output matches the shape =CustomViewCreateInput.filterData= accepts (via a live single-create call against a throwaway view that gets deleted on tear-down). No new helper. Spec § Filter mapping.

,** TODO [#B] view-sync: pearl-sync-saved-query-to-linear + transient entry   :feature:next:
User-facing command under =C-; L f S= (and ="S"= in the transient's Fetch group). Reads a saved query, runs the collapsed enriched scope-and-visibility prompt (default = =[ Team: <filter-team>, visible to the team ]= if filter has =:team=, else =[ Personal, only I see it ]=), handles Replace/Rename/Cancel on name collision, calls =customViewCreate= or =customViewUpdate=, writes =:linear-view-id= / =:linear-view-team-id= / =:linear-view-shared= / =:linear-view-synced-at= back to the entry. On the rare =customize-save-variable= failure after a successful API call, the error message must name the orphan view's id explicitly so the user can find it in Linear or re-sync with Replace. Spec § User-facing command surface, § Review disposition Q6.

,** TODO [#B] view-sync: extend pearl-delete-saved-query with delete-on-Linear prompt   :feature:quick:solo:
Add a second confirmation for entries carrying =:linear-view-id=: delete the linked view on Linear (=customViewDelete=), unlink only, or cancel. Local-only entries unchanged. Spec § User-facing command surface → Extended: pearl-delete-saved-query. Probe confirmed Linear has =customViewDelete= but no =customViewArchive=.

,** TODO [#B] view-sync: pearl-pick-source distinguishes [saved] / [saved↑] / [view]   :feature:quick:
Picker label gains the synced-vs-local marker; dispatch routes =[saved↑]= entries through =pearl-run-view= against =:linear-view-id= rather than re-running the local filter. Spec § Picker label + § Dispatch logic.

,** TODO [#B] view-sync: pearl-publish-current-source + binding   :feature:quick:solo:
Read the active buffer's =#+LINEAR-SOURCE=; if it's a named filter source matching a local saved query, route to sync-up. Skip if it's a view or a transient filter. Bind under =C-; L f P= (capital P for Publish, parallel to =f p= = by-project). Transient entry letter to be picked at implementation time (=P= is taken; lean toward =U= for "upload").

,** TODO [#B] view-sync: README Sources section gets sync-up + label conventions   :feature:quick:solo:
New README subsection under Sources covering: how to sync a saved query up, the picker-label triad ([saved] / [saved↑] / [view]), the archive-on-delete prompt, and the one-way push model. Spec status moves to =Done=.

,** TODO [#B] view-sync: tests + manual verify checklist for sync flow   :test:solo:
Unit tests for the filter-to-filterData mapping, the entry-extension shape, the dispatch logic, and the archive-on-delete branch. Plus manual-verify entries under "Manual testing and validation" mirroring the spec's =Acceptance criteria=: create-view round-trip, update-in-place, archive-vs-unlink, picker label triad, name-collision prompt, mapping refuse on unsupported dimension, sort/order mapping. Each test entry as its own =*** VERIFY= under the parent so resolution follows the [[file:../../rulesets/claude-rules/verification.md][verification.md]] flow.
#+end_src

* Out of scope (vNext)

- Two-way sync (pull view changes back into a saved query as a snapshot).
- Bulk sync ("publish every local saved query").
- Sharing-mode toggles (private view, team view, organization-wide view) beyond the v1 personal/team binary.
- Sync of saved-query =:sort= / =:order= changes after the initial sync (v1 syncs them at create-or-update time, no per-field diffing).
- A "last synced at" indicator in the picker.
- Conflict detection on update (Linear view edited since last sync) — v1 is push-overwrites; the safety prompt is a vNext layer.
- Multi-account sync routing (covered by the multi-account spec).

* Review dispositions

** 2026-05-28 — Q1 (Linear API shape) and Q4 (archive vs delete): resolved by live-API probe

Ran an introspection probe against =https://api.linear.app/graphql= with Craig's API key. Findings:

- Mutations on the Linear schema: =customViewCreate=, =customViewUpdate=, =customViewDelete=. *No =customViewArchive=.* Q4 resolves to delete-only.
- =CustomViewCreateInput.filterData= is type =IssueFilter= -- the exact type pearl's existing =pearl--build-issue-filter= already produces. Q1 resolves favorably: no new mapping helper is needed, the existing compiler IS the mapping.
- Scope fields on the input: =teamId= (optional), =projectId= (optional), =initiativeId= (optional), plus a separate =shared: Boolean= flag. v1 surfaces team + shared only; project/initiative scope moves to vNext.
- No sort/order input on =CustomViewCreateInput= or =CustomViewUpdateInput=. v1 doesn't sync sort/order; synced views use Linear's defaults. Moves to vNext.
- =CustomViewUpdateInput= is symmetric with Create minus the id (which is the targeting arg). All fields optional, so partial updates work (update just =filterData=, leave name/team alone).

Effect on the spec: § Filter mapping collapses to a one-liner. § Extended pearl-delete-saved-query renames "archive" → "delete." § Sort and order moves the sync intent to vNext. Schema extension adds =:linear-view-shared=. Implementation phase 1 trivializes (no new helper). New open question Q7 (shared default) surfaced by the probe.

** 2026-05-28 — Q2 (personal-vs-team default) and Q7 (shared default): collapsed into one enriched prompt

Craig (2026-05-28): Q2's option 1 — default to the team named in the filter's =:team= if present, else personal. Plus the broader observation: "have we informed the user if this choice is consequential in any way? this almost feels as if we can collapse two levels together by adding more information to each individual choice."

Resolution: collapse the team-scope prompt and the shared-flag prompt into one enriched prompt where each candidate spells out the complete end-state. Most-common-on-top per the existing =pearl--with-sentinel= pattern. The default candidate becomes =[ Team: <filter-team>, visible to the team ]= when the filter has =:team= (publishing-a-team-view being the typical sync intent), or =[ Personal, only I see it ]= when not. The meaningless "personal scope + shared" combination is absent from the candidate list.

Effect on the spec: § User-facing command surface → New: pearl-sync-saved-query-to-linear, steps 2 and 3 collapse into one enriched prompt; § Open questions, Q2 and Q7 close. This is also a fifth catalog candidate for the rulesets pattern discussion: *collapse N orthogonal prompts into one enriched prompt where each candidate is a complete end-state*, sibling to "the prompt label matches what the prompt does" and "default the most-common choice."

** 2026-05-28 — Q6 (failure handling on partial sync): surface + log + idempotent re-sync

Recommendation accepted: option 1. On =customViewCreate= success followed by =customize-save-variable= failure, pearl messages the user with the orphan view's id ("view created on Linear as ABC... but local link couldn't be saved; re-run pearl-sync-saved-query and pick Replace to reconcile"). The next sync run on the same entry hits the existing-name collision prompt, and Replace updates the orphan to match. The orphan is recoverable through normal flow.

Rationale: the failure mode is rare (=customize-save-variable= failing means disk/hook trouble, which is unusual and the user will know about it from other signals); the recovery path uses the same flow as an explicit re-sync (idempotent); options 2-4 add API calls, state machines, or persist-then-mutate patterns to defend against an unlikely failure where the user can already recover. Add to the spec's Implementation phase 2 task description that the error message must name the orphan id explicitly so the user can find the view in Linear if Replace isn't desired.

Effect on the spec: § Open questions empties (status moves to Ready). § Implementation tasks: phase 2's entry adds a one-line note about the orphan-id messaging requirement.

** 2026-05-28 — Phase 1 round-trip probe: pearl filter output works as customView filterData

Ran a live round-trip against api.linear.app to close phase 1:

1. =pearl--build-issue-filter '(:open t :assignee :me)= →
   =(("assignee" ("isMe" ("eq" . t))) ("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))=
2. =customViewCreate= called with that structure as =filterData=, =shared: false=, no team. Result: =success=t=, view created with id =4354f3e7-1f94-4ea7-8ba8-6f24d3ee2944=.
3. =customView(id:)= read back the identical =filterData= structure. Linear preserved the shape exactly.
4. =shared=false= and =team=nil= persisted as personal, as expected.
5. =customViewDelete= cleaned up the throwaway view (=success=t=).

Phase 1 is therefore complete. Pearl's existing compiler is the mapping; no new helper or schema-translation layer is needed. Phase 2 (the user-facing command) can consume =pearl--build-issue-filter='s output as =filterData= verbatim.

Probe script archived at =/tmp/view-sync-probe.el= (not committed; one-off verification). The probe pattern (build via compiler → create → read back → delete) is what phase 7's slow-tagged integration test will codify if Craig wants a permanent regression guard.

** 2026-05-28 — Q5 (pearl-publish-current-source v1 vs vNext): ship in v1 + bind

Craig (2026-05-28, revised same-day): option 3 -- ship the convenience wrapper in v1 *and* bind it. The trigger ("I'm reading this saved query rendered in pearl right now and want to publish it") deserves a one-chord answer rather than an =M-x= round-trip; if we're shipping the wrapper anyway, binding it lands the muscle-memory shape at the same time.

Effect on the spec: § User-facing command surface → New: pearl-publish-current-source gains =C-; L f P= as the binding (capital P for Publish, parallel to lowercase =f p= = by-project). The transient entry letter is implementation-time detail since =P= is taken in the transient's flat namespace; tentative =U= for "upload" in Fetch. Implementation phase 5's task description updated to include the binding. The fetch sub-group is now closer to "source operations" (run + sync + publish) than pure fetch; the verb-prefix shape revisit is the existing [#B] keybinding-shape-review task, not this spec.

** 2026-05-28 — Q3 (picker marker): enriched label =[saved → <scope>]=

Craig (2026-05-28): option 2 -- enriched label showing the destination scope, applying the same pattern as the Q2 collapse. Synced saved queries render as =[saved → Engineering] Name= or =[saved → Personal] Name= in =pearl-pick-source=. Local-only stays =[saved]=. View favorites stay =[view]=. The shared flag isn't surfaced at picker time (the user is running, not editing).

Effect on the spec: § Extended pearl-pick-source candidate label gets the new table. Implementation phase 4's task description and acceptance criteria mention the new label format. Q3 closes.