diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-24 18:46:43 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-24 18:46:43 -0500 |
| commit | 99908a72283e16f060239cff176a3b971e509d3c (patch) | |
| tree | c24d78248caa2ef9d4eb8b34307f9bcd9b8f0143 | |
| parent | 0683e6aeb0d4a3855dd841611ccfbb6221cfb506 (diff) | |
| download | pearl-99908a72283e16f060239cff176a3b971e509d3c.tar.gz pearl-99908a72283e16f060239cff176a3b971e509d3c.zip | |
docs: add the ticket-save-model and multi-account specs
Two design specs for upcoming work. ticket-save-model-spec covers the unified "save the ticket" model — diff title/description/comments against their provenance hashes and push only what changed, with a sequential conflict-aware engine and an opt-in keymap — and went through two review rounds (Codex). multi-account-spec covers switching between work and personal Linear workspaces over the existing globals, with auth-source credentials and per-account cache isolation; it's still pre-review draft.
| -rw-r--r-- | docs/multi-account-spec.org | 122 | ||||
| -rw-r--r-- | docs/ticket-save-model-spec.org | 253 |
2 files changed, 375 insertions, 0 deletions
diff --git a/docs/multi-account-spec.org b/docs/multi-account-spec.org new file mode 100644 index 0000000..32a7527 --- /dev/null +++ b/docs/multi-account-spec.org @@ -0,0 +1,122 @@ +#+TITLE: pearl — Multi-Account Support Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*DRAFT — design proposal; nothing in =pearl.el= has changed.* Open questions for Craig at the end. + +Lets one Emacs talk to more than one Linear workspace — a work account and a personal account — and switch between them without re-customizing. Raised by Craig: "I have a work account and a personal account. I would like to choose which account I'm working on easily and switch back and forth between the two fairly straightforwardly." + +* Problem + +Everything that identifies a workspace is a single global value today: + +- =pearl-api-key= — one key. +- =pearl-graphql-url= — one endpoint (always the same for Linear, but conceptually per-account). +- =pearl-org-file-path= — one active file. +- =pearl-default-team-id= — one team. +- the lookup caches (=pearl--cache-teams=, =-states=, =-team-collections=, =-views=, =-viewer=) — global, and *workspace-specific*: a personal account's teams and a work account's teams must never bleed together. + +To work the other account you'd re-customize the key (and team, and file) by hand and clear the cache. There's no notion of "which account am I on." + +* Current state + +- =pearl-api-key= is a plaintext defcustom, optionally loaded from =LINEAR_API_KEY= via =pearl-load-api-key-from-env=. =pearl--headers= reads it directly and errors when unset. +- =pearl-org-file-path= defaults to =gtd/linear.org=; one file holds the active view. +- The caches are module-level =defvar=s; =pearl-clear-cache= resets them all. Nothing scopes them to a workspace, so switching keys without clearing the cache would serve stale (wrong-account) teams/views/viewer. +- =pearl--cache-viewer= caches "who am I" — inherently per-account. + +* Proposed design + +** The account model + +A new defcustom =pearl-accounts=: an alist of named accounts, each a plist of the per-workspace settings. + +#+begin_src elisp +(setq pearl-accounts + '(("work" :api-key-source (auth-source "api.linear.app" "work") + :org-file "~/org/work-linear.org" + :default-team-id "TEAM_WORK") + ("personal" :api-key-source (auth-source "api.linear.app" "personal") + :org-file "~/org/personal-linear.org" + :default-team-id nil))) +#+end_src + +Each entry carries what's currently global: the credential, the org file, the default team, and (rarely needed) the endpoint. =:api-key-source= is how the key is *found*, not the key itself (see Credentials below). + +=pearl-active-account= holds the current account name. =pearl-default-account= picks the one used at startup. + +** Switching accounts + +=pearl-switch-account= (interactive): =completing-read= over =pearl-accounts= names, then: + +1. Set =pearl-active-account=. +2. Resolve and bind the active account's settings — the key, the org file, the default team — so the existing code keeps reading =pearl-api-key= / =pearl-org-file-path= / =pearl-default-team-id= unchanged (they become "the active account's values"). +3. *Clear the lookup caches* — they're workspace-specific; a switch must invalidate teams/states/collections/views/viewer so the next lookup refetches against the new workspace. +4. Optionally surface the new account's org file. + +The cleanest implementation keeps the rest of the package oblivious: a single resolver sets the three global-looking variables from the active account, and everything downstream (=pearl--headers=, =pearl--update-org-from-issues=, =new-issue=) works as-is. The accounts layer is a front-end over the existing globals. + +** Credentials + +Storing two API keys in plaintext customize is the wrong default. *Proposed: resolve keys through =auth-source=* (so =~/.authinfo.gpg= holds them), keyed per account. =:api-key-source= names the host + user to look up. A literal =:api-key "lin_..."= form stays supported as an escape hatch (and for the env-var path), but the documented default is encrypted auth-source. This also fixes a latent issue with the single-key model — the key sits in customize today. + +** Cache isolation + +Two options (open question 1): + +- *Clear on switch* (simpler): the caches stay global =defvar=s; =switch-account= clears them. Correct as long as every switch goes through the command. Minimal change. +- *Keyed by account* (stricter): the caches become per-account maps, so switching back to "work" reuses its warm cache instead of refetching. More code, better ergonomics for frequent flippers. + +Lean: clear-on-switch for v1 (it reuses =pearl-clear-cache=), keyed caches as a vNext nicety. + +** The active-file question + +Each account gets its own =:org-file= (proposed), so "work" issues and "personal" issues never share a buffer. Switching accounts switches which file is active. The alternative — one file with a per-account section — fights the active-file model (one view at a time) and risks cross-account =LINEAR-ID= collisions in the same buffer. Per-account files keep the existing single-active-view model intact, just parameterized by account. + +** What's account-scoped + +Everything that hits the API or writes the file: list/view/saved-query fetches, all the sync/save commands, field setters, comment commands, =new-issue=, the caches, the viewer identity (for comment-edit permission — "your own comments" is per-account). The account is resolved once at switch time into the globals the commands already read, so no command needs to know about accounts individually. + +* Proposed v1 decisions (this feature) + +1. =pearl-accounts= alist (name → plist of key-source / org-file / default-team / optional url); =pearl-active-account= + =pearl-default-account=. +2. =pearl-switch-account= sets the active account, resolves its settings into the existing global variables, and clears the caches. +3. Credentials resolve through =auth-source= by default; a literal key stays supported. +4. One org file per account (=:org-file=); switching switches the active file. +5. The accounts layer is a front-end over the current globals — downstream commands are unchanged. + +* Files touched + +- =pearl.el=: =pearl-accounts= / =pearl-active-account= / =pearl-default-account= defcustoms; =pearl-switch-account= + a =pearl--resolve-account= helper that binds the globals; an =auth-source= key resolver; a one-time migration of the legacy single-key config; a transient/keymap entry for switching. +- =docs/=: this spec. +- =README.org=: a multi-account setup section (auth-source entries, example =pearl-accounts=). + +* Test plan + +- =pearl--resolve-account=: a named account's plist sets the expected key / org-file / default-team; an unknown name errors. +- =switch-account= clears all five caches (assert each is nil after). +- auth-source resolver: a stubbed =auth-source-search= yields the key; a missing entry gives a clear error naming the account, not a generic "key not set". +- back-compat: with =pearl-accounts= nil and the legacy =pearl-api-key= set, everything works exactly as before (single-account path). +- viewer cache: switching accounts invalidates =pearl--cache-viewer= so comment-edit permission re-resolves against the new account. + +* Migration + +Back-compatible. With =pearl-accounts= unset, the package behaves exactly as today off the legacy =pearl-api-key= / =pearl-org-file-path= / =pearl-default-team-id=. First-run-with-accounts can offer to seed a single "default" account from the legacy values. The credential move to auth-source is opt-in: the literal-key form keeps working, so no one is forced to migrate secrets to adopt accounts. + +* Open questions for Craig + +1. *Cache strategy*: clear-on-switch (simple, refetches after every flip) or per-account keyed caches (warm on switch-back, more code)? How often do you actually flip — a few times a day, or constantly? +2. *Credential default*: auth-source / =~/.authinfo.gpg= as the documented path, with literal-key as the escape hatch — good? Or do you keep keys somewhere else (a password manager, env vars per account)? +3. *Active file*: one file per account (proposed) versus a single file you re-point — any reason you'd want both accounts' issues reachable without switching? +4. *Switch surface*: a plain =M-x pearl-switch-account=, plus a mode-line indicator of the current account? The indicator matters most — pushing a work edit while you think you're on personal (or vice versa) is the failure mode worth guarding against. +5. *Endpoint*: is =graphql-url= ever actually different per account (self-hosted / EU region), or is per-account =:url= over-engineering for two linear.app workspaces? + +* vNext / out of scope + +- Per-account keyed caches (if clear-on-switch proves annoying). +- Showing both accounts at once (split buffers / frames) — explicitly out; the model is one active account at a time. +- Per-account =pearl-saved-queries= (v1 shares the saved-query list across accounts; revisit if names collide or filters don't translate). +- Auto-detecting the account from the active file when you visit it. diff --git a/docs/ticket-save-model-spec.org b/docs/ticket-save-model-spec.org new file mode 100644 index 0000000..8d7bc9a --- /dev/null +++ b/docs/ticket-save-model-spec.org @@ -0,0 +1,253 @@ +#+TITLE: pearl — Unified Ticket Save Model & Keybinding Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*Review incorporated through round 2 (Codex, 2026-05-24). Implementation-ready pending Craig's final go.* Round 1's six blocking findings and round 2's comment-ownership blocker are dispositioned and folded into the body; modified recommendations are recorded under "Review dispositions". Open questions are resolved into "Agreed decisions"; none remain blocking. + +Covers two coupled changes: a unified "save the ticket" model that replaces the per-field sync commands as the primary editing path, and an opt-in keybinding scheme organized around it. Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (the field provenance hashes this builds on), [[file:issue-conflict-handling-spec.org][issue-conflict-handling-spec.org]] (the conflict gate each field runs through), and [[file:multi-account-spec.org][multi-account-spec.org]] (the viewer identity the comment save depends on is per-account). + +* Problem + +Editing a ticket today means picking the right per-field command: + +- Edit the heading, run =pearl-sync-current-issue-title=. +- Edit the body, run =pearl-sync-current-issue= (or compose it via =pearl-compose-current-description=). +- Edit a comment in place, run =pearl-edit-current-comment=. + +Three free-text fields, three commands, three things to remember. There's no single "I edited this ticket, save it" action, and no way to edit several fields (or several tickets) and push them all at once. In practice the save command would be the most-used command in the package, yet every field command currently has its own keybinding competing for finger memory. + +* Current state + +- *Provenance hashes* live in each issue's drawer and already encode "did this change since fetch": + - =LINEAR-TITLE-SHA256= — hash of the displayed (bracket-stripped, title-cased, prefix-excluded) title. + - =LINEAR-DESC-SHA256= — hash of the description *markdown* (Linear stores markdown; this is the remote-conflict baseline). + - =LINEAR-DESC-ORG-SHA256= — hash of the rendered *Org* body. This exists precisely because the markdown round-trip is lossy: =pearl--subtree-dirty-p= uses it so a clean ticket whose content doesn't survive =org->md= round-trip isn't falsely flagged dirty. + - per comment: =LINEAR-COMMENT-SHA256= + =LINEAR-COMMENT-ID=. +- *Each per-field command already does the diff.* =pearl-sync-current-issue= hashes the local body, compares, short-circuits to a no-op when unchanged; otherwise fetches the remote, runs the pure three-way =pearl--sync-decision= (=:noop= / =:push= / =:conflict=), and dispatches through =pearl--commit-sync-decision=. Title and comment sync mirror this, sharing the gate. *But these are interactive commands that message from async callbacks* — they take no callback, return no outcome, and don't expose whether a field was pushed, unchanged, conflicted, or cancelled. +- *Conflict resolution* (=pearl--resolve-conflict=) offers cancel / use-local / use-remote, plus an smerge rewrite buffer whose result arrives later via =pearl--conflict-commit= / =pearl--conflict-abort=. +- *Field setters* (=pearl-set-priority= / =-state= / =-assignee= / =-labels=) push immediately via =pearl--push-issue-field=. Nothing to diff — the command *is* the edit. +- *Viewer identity* (=pearl--viewer-async=, cached in =pearl--cache-viewer=) backs =pearl--comment-editable-p= — comment editing is gated to the viewer's own comments. +- *Subtree iteration* exists: =pearl--issue-subtree-markers= walks every issue heading (used by the merge refresh). +- *Discoverability* is the transient =pearl-menu=. + +* Proposed design + +** Architecture: a small save engine, not wrappers over interactive commands + +The interactive commands message from callbacks and can't be orchestrated by reading side effects. The implementation is a layered save engine: + +1. *Pure/local dirty scanners* — =pearl--issue-dirty-fields= returns which of title / description / own-comments changed, with no network calls. +2. *Per-field async savers* — each accepts a marker + a callback and emits one structured outcome (below). These hold the fetch + three-way gate + push logic currently inside the interactive commands. +3. *A sequential queue runner* — drives a list of per-field savers one at a time, collecting outcomes. +4. *Thin interactive wrappers* — the existing =pearl-sync-current-issue= / =-title= / =pearl-edit-current-comment= become thin wrappers over the per-field savers (so they keep working and gain the structured outcome), and =pearl-save-issue= / =pearl-save-all= are wrappers over the queue runner. + +The field setters stay separate — they are immediate mutations, not free-text saves. + +** Dirty detection + +=pearl--issue-dirty-fields= over the issue subtree at point: + +- *Title* dirty if =secure-hash= of the displayed (prefix-stripped) title ≠ =LINEAR-TITLE-SHA256=. +- *Description* dirty if =secure-hash= of =pearl--issue-body-at-point= (the rendered Org) ≠ =LINEAR-DESC-ORG-SHA256=. *Fall back* to =org->md= vs =LINEAR-DESC-SHA256= only for legacy rendered subtrees that predate the Org hash. This avoids the false-dirty trap: scanning via the markdown hash would mark clean-but-lossy tickets dirty and fire pointless fetches. The remote *conflict gate* still hashes markdown against =LINEAR-DESC-SHA256=, because Linear stores markdown. +- *Comment* dirty detection is *two-phase* because ownership needs the viewer id, which the local scan doesn't have: + - *Phase A — changed candidates (local only).* A comment is a /changed candidate/ if =secure-hash= of =pearl--org-to-md= of its body ≠ its =LINEAR-COMMENT-SHA256=. The hash must run through =org->md= because =LINEAR-COMMENT-SHA256= is taken over the *markdown* Linear stored (the comment renders via md→org), matching exactly how =pearl-edit-current-comment= computes its no-op check. Hashing the raw Org body would be wrong. + - *Phase B — ownership classification.* Only if changed candidates exist, resolve the viewer once (below) and classify each candidate as an /own dirty comment/ (queued to push) or =skipped= / =read-only= (a non-own / bot / external comment edited locally — reported, not silently dropped). + +On a successful description push, advance *both* =LINEAR-DESC-SHA256= and =LINEAR-DESC-ORG-SHA256= so the next scan is lossless. + +** Internal save-outcome contract + +Every per-field saver invokes its callback exactly once, only after its *final* outcome is known, with this plist: + +#+begin_src emacs-lisp + (:issue-id "uuid" + :identifier "ENG-123" + :field title | description | comment + :comment-id "comment-id-or-nil" + :status pushed | unchanged | conflict | resolved-remote | skipped | failed + :reason nil | read-only | viewer-unavailable | missing-property + | fetch-failed | push-failed | cancelled | aborted + :label "ENG-123 description" + :message "human-readable detail") +#+end_src + +=:status= is the small machine-testable set; =:reason= carries the why for the non-success cases. =save-issue= and =save-all= aggregate *only* these outcomes — never message-scraping. + +** Sequential queue semantics + +The queue runner starts the next dirty field only after the current field's callback has fired. This guarantees at most one conflict-resolution UI is live at a time. Outcome timing per resolution path: + +- *No remote difference, clean push* → =pushed= when the update callback returns (=failed= / =push-failed= if it errors). +- *Unchanged* → =unchanged=, no network beyond the dirty scan's already-known state. +- *Conflict, user cancels the prompt* → =conflict=, =:reason cancelled= (the field is still in conflict; cancelling didn't resolve it). +- *Conflict, use-local* → =pushed= (or =failed= / =push-failed=) after the overwrite push returns. +- *Conflict, use-remote* → =resolved-remote= after Linear's text is applied locally and the hashes advance (no push). +- *Conflict, smerge rewrite* → the saver's callback does not fire until =pearl--conflict-commit= (=pushed=/=failed=) or =pearl--conflict-abort= (=conflict=, =:reason aborted=). The queue does *not* advance to the next field while an smerge buffer is pending. + +** Comment ownership and viewer lookup + +Comment saving depends on viewer identity, which is async. Ownership classification (Phase B above) needs it, so the viewer is resolved *once per save* — not once per comment: + +- Resolve the viewer *once* when changed comment candidates exist: at the start of =pearl-save-issue= (before queueing), and once for the whole batch in =pearl-save-all=. Reuse =pearl--cache-viewer= when warm; a cold cache costs one read-only lookup. +- If viewer resolution *fails*, skip comment edits (=skipped=, =:reason viewer-unavailable=) and still let title/description save proceed. +- Non-own / bot / external comments edited locally → =skipped=, =:reason read-only=, surfaced in the summary so the user understands why their edit didn't push. + +*Multi-account is not a prerequisite.* The current single-account implementation uses the existing global =pearl--cache-viewer=. The reference to [[file:multi-account-spec.org][multi-account-spec.org]] is forward-looking only: when multi-account lands, account switching must invalidate or scope the viewer cache as that spec specifies. This feature does not wait on it. + +** =pearl-save-issue= — diff the ticket at point, push only what changed + +Run from anywhere inside an issue subtree: + +1. Local dirty scan (no network). If nothing's dirty, report "nothing to save" and stop — no fetch. +2. Resolve the viewer once if comments are dirty. +3. Queue the dirty fields and run them sequentially. +4. Report one summary grouping the outcomes: =Saved ENG-1: 1 title, 1 description pushed; 1 comment skipped (read-only); 2 unchanged=. + +No confirmation prompt — =save-issue= is issue-scoped and explicit (you ran it on this ticket). Each field's content is re-read and re-gated at push time, so the scan picks the work list and the push validates against the live remote. + +** =pearl-save-all= — every ticket in the file, confirmed, in one pass + +A single key must not silently push many remote mutations. The guarantee is *no remote mutation before the user confirms* — relaxed from "no remote calls" to allow the one read-only viewer lookup needed to count read-only comment skips accurately. So: + +1. *Local dirty scan first* across all =pearl--issue-subtree-markers= — no network for title/description; comment changed-candidates found locally (Phase A). +2. If nothing's dirty, report "nothing to save" and stop — no network at all. +3. *If changed comment candidates exist and the viewer cache is cold, run one read-only viewer lookup* (not a mutation) so read-only comments can be classified and counted. If that lookup fails, proceed to confirm title/description and state that comments will be skipped (viewer unavailable). +4. *Prompt once* before any mutation, naming the scope by field type: =Save 7 fields across 3 Linear issues? (2 titles, 4 descriptions, 1 comment; 1 read-only comment skipped)=. Declining does no further fetch and no mutation. +5. On confirm, run the queue across all dirty fields, continuing *past* a per-ticket conflict (that field is left untouched and reported; the rest still save). +6. Progress messages name the current issue/field; the final report aggregates =pushed / unchanged / skipped / conflict / failed= with reasons for the skips and failures. + +*Snapshot rule:* the dirty-field work list is captured at the initial scan. Edits made *after* the scan aren't included until the next save. (Content is still re-read and re-gated at each field's push, so a stale snapshot can only under-include, never push the wrong text.) A malformed or missing drawer on one issue is counted =failed= / =skipped= with a useful label, never aborts the batch. + +** Field setters stay immediate + +=set-priority= / =-state= / =-assignee= / =-labels= do not fold into =save-issue= — no free-text state to diff, and they already push on selection. They keep immediate behavior and are surfaced under the edit prefix for discoverability only. + +** Compose buffers stay direct-push (v1) + +=pearl-compose-current-description= and the interactive =pearl-add-comment= keep their explicit =C-c C-c= submit-and-push behavior. Deferring a composed-but-unsent comment to =save-issue= would need a new local "unsent comment" representation before Linear assigns a comment id — a separate design. The unified save covers *in-place* edits; the compose buffers remain the focused-editing path that pushes on submit. + +** Keybinding scheme — opt-in =pearl-prefix-map= + +The package does *not* bind a global prefix at load time (=C-;= isn't reliably free across terminals/GUIs, and auto-installing a multi-key global prefix is a compatibility decision the user should own). Instead it *defines* a prefix keymap the user binds: + +#+begin_src emacs-lisp + (define-prefix-command 'pearl-prefix-map) + ;; ... pearl populates pearl-prefix-map with the bindings below ... + + ;; user config — pick a prefix that's free in your setup: + (global-set-key (kbd "C-; L") pearl-prefix-map) + ;; or, with use-package: + ;; :bind-keymap ("C-; L" . pearl-prefix-map) +#+end_src + +No imperative installer is shipped — the =define-prefix-command= + documented binding snippet is the idiomatic, package-safe path. The README documents =C-; L= as a *suggested* prefix, not a default. Map contents (mnemonic: add / delete / edit): + +#+begin_example +<prefix> + a add a t add ticket (pearl-new-issue) a c add comment + d delete d t delete ticket (pearl-delete-current-issue) + e edit/save e e save this ticket (pearl-save-issue) ← primary + e a save all tickets (pearl-save-all) + e d compose description (pearl-compose-current-description) + e c edit comment (pearl-edit-current-comment) + e p set priority e s set state e n set assignee e l set labels + m menu the full transient (pearl-menu) +#+end_example + +The fetch/view/setup commands stay on the transient, reached with =m=. The transient is the discovery surface; the keymap is muscle memory. + +** Transient menu changes + +The "Issue at point" group loses the two field-sync entries (subsumed by save) and gains the save commands: + +| Before | After | +|--------+-------| +| =e= Edit desc → push (=sync-current-issue=) | =e= *Save ticket* (=pearl-save-issue=) | +| =t= Edit title → push (=sync-current-issue-title=) | =E= *Save all* (=pearl-save-all=) | +| =s= Set state | =s= Set state | +| =a= Set assignee | =a= Set assignee | +| =P= Set priority | =P= Set priority | +| =L= Set labels | =L= Set labels | +| =c= Add comment | =c= Add comment | +| =M= Edit comment | =M= Edit comment | +| =D= Compose desc → push | =D= Compose desc → push | +| =k= Delete issue | =k= Delete issue | +| =o= Open in browser | =o= Open in browser | + +(The transient and the =pearl-prefix-map= are independent surfaces; the table above is the transient. The menu's existing key locks in transient tests, so this is the exact target.) + +* Agreed decisions (this feature) + +1. A layered save engine: pure dirty scanners → per-field async savers (marker + callback → structured outcome) → sequential queue runner → thin interactive wrappers. +2. Description dirty detection uses =LINEAR-DESC-ORG-SHA256= first, falling back to the markdown hash only for legacy subtrees; a push advances both hashes; the remote gate still uses the markdown hash. +3. One structured field-outcome plist everywhere; =:status= ∈ =pushed|unchanged|conflict|resolved-remote|skipped|failed=, detail in =:reason= / =:message=. +4. Dirty fields push sequentially; the queue never advances while a conflict-resolution buffer is pending; cancel/abort report =conflict= with a reason. +5. Comment dirty detection is two-phase: Phase A finds changed candidates locally via =hash(org->md body) ≠ LINEAR-COMMENT-SHA256=; Phase B resolves the viewer once (only when candidates exist) and classifies own-dirty vs =skipped/read-only=. Viewer failure skips comments but lets title/description proceed. Multi-account is not a prerequisite — single-account uses the global =pearl--cache-viewer=. +6. =save-all='s guarantee is no remote *mutation* before confirmation. It scans dirty fields locally, may run one read-only viewer lookup pre-confirmation (when comment candidates exist and the cache is cold) so the prompt counts read-only skips accurately, then prompts once naming counts; declining mutates nothing. =save-issue= skips confirmation (issue-scoped) but keeps the local no-op fast path. +7. =save-all= snapshots the dirty work list at scan time; later edits wait for the next save; per-field content is re-read and re-gated at push. +8. Field setters stay immediate; compose buffers stay direct-push in v1. +9. Keybindings ship as an opt-in =pearl-prefix-map= (no global bind at load); README documents =C-; L= as a suggested binding. Verb layout =a= / =d= / =e= + =m=. +10. The transient "Issue at point" group is retargeted per the table above; the per-field sync commands stay callable but lose dedicated keys. + +* Files touched + +- =pearl.el=: =pearl--issue-dirty-fields= (local scanners); per-field async savers extracted from the current interactive commands; the sequential queue runner; =pearl-save-issue= / =pearl-save-all=; the interactive sync commands re-pointed as thin wrappers; =pearl-prefix-map= (define-prefix-command, populated, not bound); the retargeted =pearl-menu= group; description-push advancing both hashes. +- =docs/=: this spec. +- =README.org=: the save model + the suggested-binding snippet, with the existing title-bracket and markdown-lossiness warnings kept *near* the save docs (a unified save pushes title + description together, so the losses surface in one command). + +* Test plan + +New focused =tests/test-pearl-save.el= (rather than scattering into the per-field files): + +*Dirty scan* +- Clean rendered issue whose markdown is lossy under =org->md= → empty (via =LINEAR-DESC-ORG-SHA256=). +- Legacy issue lacking the Org hash → falls back to the markdown hash. +- Reports title-only, description-only, own-comment-only, multiple own comments, and mixed title+description+comment. +- Comment candidate detection hashes =org->md= of the body (matching =LINEAR-COMMENT-SHA256=): a comment edited only in ways that survive the md round-trip is dirty; a clean comment is not flagged. +- Phase A (candidates) runs with no viewer lookup; Phase B classifies own vs read-only only when candidates exist; no candidates → no viewer lookup at all. +- Edited non-own comment → classified =skipped/read-only=, not dirty-to-push. + +*save-issue (HTTP stubbed)* +- Clean ticket → no fetch/update calls. +- Pushes dirty fields sequentially; records ordered outcomes. +- Per-field conflict reported; that field's hash untouched; other dirty fields still push. +- Two conflicts → the second prompt doesn't open until the first resolution callback completes. +- Smerge rewrite delays the next field until =conflict-commit= / =conflict-abort=. +- Push failure keeps local text + stored hash; summary reports =failed/push-failed=. +- Description push advances both =LINEAR-DESC-SHA256= and =LINEAR-DESC-ORG-SHA256=. +- Viewer lookup failure → title/description proceed, comments =skipped/viewer-unavailable=. + +*save-all* +- Scans all markers locally; with comment candidates and a cold viewer cache, runs exactly one read-only viewer lookup before the prompt, and zero mutations; declined confirmation does no mutation. +- No comment candidates → no viewer lookup before the prompt; clean file → no network at all. +- Pre-confirmation viewer lookup failure → still prompts for title/description, names comments as skipped (viewer unavailable). +- Aggregates =pushed/conflict/failed/skipped/unchanged= counts; a conflict on ticket 2 doesn't stop tickets 1 and 3. +- Snapshot: edits after the initial scan aren't included until a later save. +- Malformed drawer on one issue → counted =failed/skipped=, batch continues. + +*Keymap / menu / docs* +- =pearl-prefix-map= is defined; =e e= reaches =save-issue=; =e a= reaches =save-all=; the map is *not* globally bound at load. +- Transient keeps the retargeted suffixes (locks the menu group). +- README examples use the final command names and keep the lossiness warnings near the save docs. + +* Review dispositions + +*Round 1 (Codex, 2026-05-24).* Everything was accepted and woven into the body, *except* the two below, which were modified. + +- *HP6 / Open question 4 — keymap install helper (modified).* Accepted the core: opt-in, no global bind at load, define =pearl-prefix-map=, document a =global-set-key= / =:bind-keymap= snippet. Declined the optional imperative =pearl-install-prefix-key= helper the review floated — =define-prefix-command= plus a documented binding is the idiomatic Emacs path, and shipping an installer invents API for something the user does in one line. The snippet is the API. +- *MP3 / Open question 3 — =cancelled= as a top-level status (modified).* The review's outcome enum listed =cancelled= alongside =conflict=. Folded =cancelled= into =conflict= with =:reason cancelled= (and smerge abort as =:reason aborted=): cancelling or aborting a conflict *leaves the field in conflict* — it didn't resolve to anything — so a separate top-level status double-counts the same end state. =:status= stays the smaller set =pushed|unchanged|conflict|resolved-remote|skipped|failed=; the cancel/abort detail lives in =:reason=, which summaries can still surface. + +Everything else — HP1 (Org-hash-first dirty detection), HP2 (structured outcome contract), HP3 (sequential continuation semantics), HP4 (viewer-once-per-batch), HP5 (save-all confirm + dry scan), MP1 (resolve open questions into decisions), MP2 (snapshot rule), MP4 (compose stays direct-push), MP5 (exact transient layout), MP6 (README lossiness warnings), and the architecture / robustness / test-strategy observations — accepted as written. + +*Round 2 (Codex, 2026-05-24).* All accepted as written; no modifications. HP1 (two-phase comment ownership) corrected a genuine contradiction this author introduced in round 1 — the "no remote calls" save-all scan couldn't count read-only comment skips without the viewer id — and pinned the comment hash to =org->md= against =LINEAR-COMMENT-SHA256= (verified against =pearl--format-comment= and =pearl-edit-current-comment=). Folded in as the two-phase rule with a permitted pre-confirmation read-only viewer lookup. MP1 (multi-account not a prerequisite) and MP2 (stale =todo.org= wording) accepted; the stale task decisions block was trimmed to point at this spec rather than carry superseded pre-spec decisions. + +* vNext / out of scope + +- Review-changes-before-save diff buffer (=git add -p= style) across the file before pushing. +- A save-all dry-run command that reports dirty fields without prompting to push. +- Parallel save execution with conflicts queued and presented at the end, once the sequential engine is stable. +- Auto-save on buffer save (its own task; depends on this model's no-op/conflict detection). +- Undo/rollback of a just-pushed change. |
