diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 19:35:07 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 19:35:07 -0500 |
| commit | 2568f25d069ecf242d5288db8f3bda968d6f8868 (patch) | |
| tree | 1ff0880222c3825c8badba07d7ecda49542ae7b4 | |
| parent | 35d9f8f7e27b4e6a7d99c7f0da9d29dfd6f2acc2 (diff) | |
| download | pearl-2568f25d069ecf242d5288db8f3bda968d6f8868.tar.gz pearl-2568f25d069ecf242d5288db8f3bda968d6f8868.zip | |
docs: add the ticket save-model v2 spec
The v2 spec folds the structured fields — priority, state, assignee, labels — onto the save engine and removes immediate push, so the package has one write path instead of two. It extends the shipped v1 save-model spec and supersedes the "field setters stay immediate" half of v1 decision 8. I incorporated two Codex review rounds.
I also added the supersession pointer to the top of the v1 spec so the v1/v2 relationship is legible from either file.
| -rw-r--r-- | docs/ticket-save-model-spec.org | 2 | ||||
| -rw-r--r-- | docs/ticket-save-model-v2-spec.org | 227 |
2 files changed, 229 insertions, 0 deletions
diff --git a/docs/ticket-save-model-spec.org b/docs/ticket-save-model-spec.org index 40066ce..dd99add 100644 --- a/docs/ticket-save-model-spec.org +++ b/docs/ticket-save-model-spec.org @@ -5,6 +5,8 @@ * Status +*SHIPPED. Superseded in part by [[file:ticket-save-model-v2-spec.org][ticket-save-model-v2-spec.org]] — v2 folds the structured fields (priority, state, assignee, labels) onto this save engine and removes immediate push, reversing the "field setters stay immediate" half of decision 8. The compose-buffers-stay-direct half of decision 8 still stands (v2 doesn't touch the compose buffers). The engine, dirty-scan, outcome contract, and keybinding scheme described here are live; read v2 for the structured-field write path.* + *Review incorporated through round 2 (Codex, 2026-05-24). Implementation-ready pending Craig's final go.* Round 1's six blocking findings and round 2's comment-ownership blocker are dispositioned and folded into the body; modified recommendations are recorded under "Review dispositions". Open questions are resolved into "Agreed decisions"; none remain blocking. Covers two coupled changes: a unified "save the ticket" model that replaces the per-field sync commands as the primary editing path, and an opt-in keybinding scheme organized around it. Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (the field provenance hashes this builds on), [[file:issue-conflict-handling-spec.org][issue-conflict-handling-spec.org]] (the conflict gate each field runs through), and [[file:multi-account-spec.org][multi-account-spec.org]] (the viewer identity the comment save depends on is per-account). diff --git a/docs/ticket-save-model-v2-spec.org b/docs/ticket-save-model-v2-spec.org new file mode 100644 index 0000000..c16a0d8 --- /dev/null +++ b/docs/ticket-save-model-v2-spec.org @@ -0,0 +1,227 @@ +#+TITLE: pearl — Ticket Save Model v2: Structured Fields on the Save Engine +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-25 +#+STARTUP: showall + +* Status + +*READY — Craig's go 2026-05-25. Rounds 1-2 review incorporated (Codex, 2026-05-25); implementation-ready.* Round 1's five blocking + four medium findings and round 2's two blocking + two medium findings are dispositioned and folded into the body; see "Review dispositions". Round 2's two blockers — the after-save full-file sync was a second undefined immediate push, and the state dirty rule needs faithful keyword rendering — are resolved: v2 removes the whole immediate-state-push org-sync (both hooks + the push functions), and keyword-derivation is now a stated prerequisite for the state keyword-cycle path (consistent with the agreed foundations-first order), with picker-id-only state as the no-companion fallback. + +Extends [[file:ticket-save-model-spec.org][ticket-save-model-spec.org]] (v1, shipped). v1 unified the three free-text fields (title, description, comments) onto a save engine but left the four structured fields (priority, state, assignee, labels) as immediate-push commands (v1 decision 8). This spec folds the structured fields onto the same engine and removes immediate push, so the package has exactly one write path. It supersedes the "field setters stay immediate" half of v1 decision 8; the "compose buffers stay direct-push" half stands (v2 does not touch the compose buffers). + +Companion to [[file:labels-as-org-tags-spec.org][labels-as-org-tags-spec.org]] (renders labels as org tags — *display-only*, not an editing path), [[file:todo-keywords-from-workflow-states-spec.org][todo-keywords-from-workflow-states-spec.org]] (makes keyword cycling reach every state), and [[file:issue-conflict-handling-spec.org][issue-conflict-handling-spec.org]] (the text conflict gate; v2 adds an atomic sibling). + +* Problem + +The package has two editing paradigms, and the seam between them is what makes it feel incoherent: + +1. *Free-text fields* (title, description, comments) — you edit the buffer, then run a save (=pearl-save-issue= / =pearl-save-all=). The engine diffs against a stored baseline and pushes only what changed. +2. *Structured fields* (priority, state, assignee, labels) — a completing-read command pushes the moment you pick a value. No buffer edit, no diff, no save. + +The save engine only knows about kind 1. Kind 2 never entered it. That split is the whole "schizophrenic" feeling: two mental models for "change a ticket," two trigger moments, and a save command that silently doesn't cover four of the fields it appears to. + +The goal: one model. You edit the buffer — including the structured fields, each through whatever affordance fits — and a save reconciles everything against the remote and pushes the diff. Immediate push goes away. + +* The reframe + +The structured setters aren't a different save model. They're a different /push trigger/ bolted onto the same data. Every field already has a buffer representation: + +| Field | Buffer representation | +|-------------+--------------------------------------------------------| +| title | heading text | +| description | subtree body | +| state | TODO keyword (live id) + =LINEAR-STATE-ID-SYNCED= base | +| priority | =[#A]/[#B]/[#C]/[#D]= cookie + =LINEAR-PRIORITY= base | +| assignee | =LINEAR-ASSIGNEE-ID= live + =-SYNCED= base; name shown | +| labels | =LINEAR-LABEL-IDS= live + =-SYNCED= base; tags shown | +| comments | child subtrees | + +So the buffer is the source of truth for all of them. The only difference is /when/ the push fires. Unify the trigger and the split is gone. + +** The editing affordance: org-native where the construct is editable and faithful, a picker otherwise + +You don't hand-type a constrained field; you pick it, and the pick edits the buffer instead of pushing. Each existing setter already has two halves: a completing-read picker (keep it) and an immediate push (remove it). The picker becomes a buffer-editor — it writes the field's representation and stops. + +A field is edited org-natively only where org has a faithful, editable construct for it. Otherwise a pearl picker is the editor: + +- *Priority* — org-native. Five Linear priorities map cleanly to None → no cookie and Urgent/High/Medium/Low → =[#A]/[#B]/[#C]/[#D]= (the package already binds =org-priority-lowest= to =?D=, so =[#D]= works today, =pearl.el:2668=). Edited via org's keys (=C-c ,= / =S-up= / =S-down=). =pearl-set-priority= is deleted. +- *State* — keyword cycling for the common case (=C-c C-t=), plus a picker (=pearl-edit-state=) for the lossy edge. The keyword reaches every state once [[file:todo-keywords-from-workflow-states-spec.org][keyword derivation]] lands, but same-team slug collisions (=Dev Review= / =Dev-Review= → =DEV-REVIEW=) leave a residual case cycling can't express by keyword alone; the picker reaches the exact state id there. +- *Labels* — picker (=pearl-edit-labels=), id-based. Org tags *display* the labels (companion spec) but are *render-only* — editing a tag does not edit the label. Bidirectional tag editing is a separate, larger design (vNext); v2 edits labels through the picker like a relation field. +- *Assignee, project, cycle, estimate* — picker (=pearl-edit-assignee=, etc.); no org-native construct. The picker writes the id and the display name. + +Free-text fields (title, description, comments) stay fully hand-editable. Everything constrained moves through a picker — org's or pearl's — never freehand. + +* Resolved forks + +1. *Authority over id-bearing fields (assignee, project, labels).* Picker-authoritative. The picker writes the live id (and the display name/tags for show). Dirty detection compares the *id* against its synced baseline, never the displayed text. A hand-edit to a display name or tag is *cosmetic and ignored* — refresh and save-success rewrite the display from the authoritative id, so the buffer reconverges. No =name-id-mismatch= reporting (see Review disposition HP5): a field whose id matches baseline is simply not dirty, so no saver runs to report anything. +2. *Conflict policy for structured fields.* An atomic enum/relation can't be text-merged, so v2 adds a sibling to the text conflict gate — =pearl--resolve-atomic-conflict= — with use-local / use-remote and *no* smerge branch. Conflict means baseline ≠ remote /and/ baseline ≠ live /and/ remote ≠ live; the prompt offers keep-mine (push) or take-theirs (adopt remote, advance baseline). It does not write SHA hashes or touch the kill ring (the text helper's machinery is wrong for ids). +3. *Immediate push — every path of it.* Removed. Two state-pushing paths exist today, both wired by =pearl-enable-org-sync=: the =org-after-todo-state-change-hook= (push on keyword cycle) and the =after-save-hook= (=pearl-org-hook-function= → =pearl-sync-org-to-linear=, a whole-file state push on buffer save). v2 unwires both and removes the push machinery (see "Org-sync removal" below). The setters also stop calling =pearl--push-issue-field=. All pushes go through the save engine. Reverses the setters half of v1 decision 8. Auto-pushing on buffer save is deferred to the separate "Automatic sync on save" task, which would call the save engine, not the old whole-file pusher. +4. *Save scope.* Keep both =pearl-save-issue= (this ticket) and =pearl-save-all= (whole buffer). No change to v1's split. + +* Current state + +- The four setters (=pearl-set-priority= / =-state= / =-assignee= / =-labels=) prompt via completing-read, push immediately via =pearl--push-issue-field= (=pearl.el:2704=, =:2738=, =:2775=, =:2805=), then write the org representation. The representation-writing half exists; only the push half is removed. +- The save engine (=pearl--issue-dirty-fields= at =pearl.el:3246=, =pearl--save-field-thunks= at =:3510=, =pearl--run-save-queue=, the save-outcome contract) handles title / description / comments only. +- =pearl-enable-org-sync= adds =pearl-sync-org-to-linear= to =org-after-todo-state-change-hook= (=pearl.el:4069=) — keyword cycling is an immediate push today. +- Issue queries already fetch the remote structured values for conflict checks: =priority=, =state { id name type }=, =assignee { id name }=, =labels { nodes { id name } }= (=pearl.el:888=, =:908=). =pearl--update-issue-async= already builds =IssueUpdateInput= for =priority=, =stateId=, =assigneeId=, =labelIds= (=pearl.el:2640=). +- The renderer writes display values: =LINEAR-STATE-ID= / =LINEAR-STATE-NAME=, the cookie, =LINEAR-ASSIGNEE-ID= / =-NAME=, =LINEAR-LABELS= (names only, =pearl.el:1914=). No separate synced baseline exists for any structured field. + +* Proposed design + +** Live value and synced baseline per field + +Reconcile-on-save needs the live value (what the affordance/picker set) separate from the baseline (last synced from Linear). Dirty = live ≠ baseline; a successful push advances the baseline. Render writes live = baseline at fetch (nothing dirty on a fresh fetch); the merge refresh advances both for any subtree it re-renders and leaves a retained dirty subtree's live alone. + +- *Priority* — live = the priority number of the current cookie (the full mapping is in "Priority cookie mapping" below: None/Urgent/High/Medium/Low ↔ 0/1/2/3/4, cookie none/A/B/C/D); baseline = =LINEAR-PRIORITY=. The cookie is the live artifact; no extra live property. +- *State* — baseline = =LINEAR-STATE-ID-SYNCED= and =LINEAR-STATE-NAME= (the synced name, kept for slug comparison). The *picker path always works*: =pearl-edit-state= writes an explicit live =LINEAR-STATE-ID=, and dirty = that id ≠ the synced baseline — no keyword faithfulness needed. The *keyword-cycle path* (slug of the current keyword ≠ slug of =LINEAR-STATE-NAME= ⇒ cycled, resolve the id in the saver) is sound *only when keyword derivation is present*, because it assumes a fresh fetch renders each state as its slug keyword. Without that companion, the static mapping renders unmapped states as the =TODO= fallback, so slug(keyword) ≠ slug(name) on a clean fetch and the issue would look falsely dirty. So the keyword-cycle dirty path is *gated on the keyword-derivation invariant* (the derived =#+TODO:= header + faithful slug rendering); until that lands, state is picker-id-only and keyword changes are not scanned as state edits. See Dependencies. +- *Labels* — live = =LINEAR-LABEL-IDS= (the picker writes the id set); baseline = =LINEAR-LABEL-IDS-SYNCED=. =LINEAR-LABELS= stays display-only (names/tags). Set comparison is order-insensitive and duplicate-free, so reordered labels don't read dirty. +- *Assignee / project / etc.* — live = =LINEAR-ASSIGNEE-ID= (picker-written); baseline = =LINEAR-ASSIGNEE-ID-SYNCED=. The display name (=LINEAR-ASSIGNEE-NAME=) is shown but never the dirty basis. + +*Priority cookie mapping (settled).* None → no cookie; Urgent → =[#A]=; High → =[#B]=; Medium → =[#C]=; Low → =[#D]=. =[#D]= already works via the existing =org-priority-lowest= =?D= binding. =LINEAR-PRIORITY= holds the numeric baseline; dirty = cookie's priority number ≠ baseline. No lossy collapse — the earlier three-cookie idea was wrong and is dropped. + +** Dirty detection extended + +=pearl--issue-dirty-fields= gains =:state=, =:priority=, =:labels=, =:assignee= beside =:title= / =:description= / =:comment-candidates=. Every structured check is id/scalar-only and network-free: priority compares cookie# vs =LINEAR-PRIORITY=; labels compare id sets (order-insensitive) vs =LINEAR-LABEL-IDS-SYNCED=; assignee compares =LINEAR-ASSIGNEE-ID= vs =-SYNCED=; state compares the explicit picker id vs =LINEAR-STATE-ID-SYNCED= always, and the keyword slug vs =LINEAR-STATE-NAME= only when keyword derivation is present (otherwise the keyword is not scanned, per the gating above). No name resolution and no remote read at scan time. The id-resolution and remote read for state happen inside the per-field saver, after the work list is known. + +** Per-field savers + the atomic conflict gate + +Each structured field gets a per-field async saver matching the v1 contract (marker + callback → one save-outcome plist). To avoid bending the text-oriented saver around enum/id fields, structured fields run through a parallel =pearl--run-atomic-field-save= with the same outcome plist. Each saver: + +1. Reads live + baseline. If equal, =unchanged= (re-checked at push). +2. Fetches the remote value for that field (the one remote read, here not at scan). +3. Atomic three-way: remote = live → =unchanged=, advance baseline. remote = baseline (remote didn't move) → clean push → =pushed=. remote ≠ baseline and remote ≠ live → =conflict= → =pearl--resolve-atomic-conflict= (use-local pushes; use-remote adopts remote, advances baseline, =resolved-remote=; no smerge). +4. On a successful push, advance the baseline and rewrite the display fields (assignee name, label tags). The mutation result from =pearl--update-issue-async= returns only =id updatedAt=, not the new relation display, so the display source is: use-local → the picker's cached live display (the name/tags it selected); use-remote → the fetched remote display already in hand from the conflict fetch. Neither path needs to expand the mutation selection set. + +=pearl--resolve-atomic-conflict= contract: prompt label, use-local callback, use-remote callback, no rewrite/smerge option, callback maps to =pushed= / =resolved-remote= / =conflict= (=:reason cancelled=). + +The save-outcome =:field= enum grows =state | priority | labels | assignee=. =:reason= keeps the existing set and adds =missing-property= (migration, below). =name-id-mismatch= is *not* added — display edits are ignored, not reported. + +** Setter conversion, renames, deletions + +Pre-release, so old command names are removed outright (no aliases). Each rename touches the command def, =pearl-menu=, =pearl-prefix-map=, README suggested keys, tests, and docstrings. + +- =pearl-set-priority= → *deleted*. Priority is org-native via the cookie; the engine reconciles =LINEAR-PRIORITY=. +- =pearl-set-labels= → renamed =pearl-edit-labels=, converted to a buffer-editor: the picker writes =LINEAR-LABEL-IDS= (live) + =LINEAR-LABELS= (display), marks dirty, no push. Kept (tags are render-only, so the picker is the label editor). +- =pearl-set-state= → renamed =pearl-edit-state=, buffer-editor: writes keyword + =LINEAR-STATE-NAME= + explicit live =LINEAR-STATE-ID=, no push. Kept for the lossy edge. +- =pearl-set-assignee= → renamed =pearl-edit-assignee=, buffer-editor: writes =LINEAR-ASSIGNEE-ID= (live) + =-NAME= (display), no push. +- =pearl-compose-current-description= → renamed =pearl-edit-description= (the rename task; its compose behavior is unchanged — v2 doesn't touch compose buffers). + +** Org-sync removal + +The =pearl-enable-org-sync= mode exists only to push state immediately, which v2 forbids. It wires two state-pushing hooks, and both go: + +- =org-after-todo-state-change-hook= → push on keyword cycle. Unwired. Cycling a keyword now marks dirty, reconciled at save. +- =after-save-hook= → =pearl-org-hook-function= → =pearl-sync-org-to-linear=, a whole-file state push on every buffer save. Unwired. Buffer save no longer pushes anything; only =pearl-save-issue= / =pearl-save-all= push. + +With both hooks gone, the push machinery has no caller and is removed: =pearl-sync-org-to-linear=, =pearl-sync-current-heading-to-linear=, =pearl-org-hook-function=, and =pearl-enable-org-sync= / =pearl-disable-org-sync=. This fully subsumes the "make the org-sync commands private" task — there's nothing left to privatize. The keyword→state-id *resolution* that keyword derivation builds is retained, but it's consumed by the state per-field saver at save time, not by an immediate push on cycle (see the cross-spec note in Dependencies). Re-introducing an auto-push on buffer save is the separate "Automatic sync on save" task, which would call the save engine. + +** Legacy-file migration + +Existing rendered files lack =LINEAR-PRIORITY=, =LINEAR-LABEL-IDS(-SYNCED)=, =LINEAR-STATE-ID-SYNCED=, =LINEAR-ASSIGNEE-ID-SYNCED=. A structured saver that finds a missing baseline returns =skipped=, =:reason missing-property=, with a message to refresh the view first (a refresh re-renders and writes all baselines). The save does not guess a baseline or push blindly. Title/description/comments on the same issue still save normally. + +** =save-all= prompt and summary + +The confirm prompt and final summary count structured fields alongside the free-text ones, e.g. =Save 6 fields across 2 issues? (1 title, 1 description, 2 states, 1 priority, 1 assignee)=. Declining performs no structured-field fetch or mutation. The summary aggregates =pushed / unchanged / conflict / failed / skipped= with reasons (including =missing-property= skips). + +** Affordance summary after v2 (+ companions) + +| Field | Edit affordance | Dirty basis | +|-------------+------------------------------------------------+--------------------------------------| +| title | hand-edit heading | =LINEAR-TITLE-SHA256= | +| description | hand-edit body | =LINEAR-DESC-ORG-SHA256= | +| comments | hand-edit / compose | =LINEAR-COMMENT-SHA256= | +| state | =C-c C-t= cycle; =pearl-edit-state= for the edge | keyword/picker id vs =-STATE-ID-SYNCED= | +| priority | =C-c ,= / =S-up/down= | cookie# vs =LINEAR-PRIORITY= | +| labels | =pearl-edit-labels= picker (tags display only) | id set vs =LINEAR-LABEL-IDS-SYNCED= | +| assignee | =pearl-edit-assignee= picker | =LINEAR-ASSIGNEE-ID= vs =-SYNCED= | + +* Agreed decisions + +1. One write path: every push goes through the save engine. The setters lose their push; *both* org-sync state-push paths (=org-after-todo-state-change-hook= and the =after-save-hook= whole-file sync) are unwired, and the push machinery (=pearl-sync-org-to-linear=, =-current-heading-to-linear=, =pearl-org-hook-function=, =pearl-enable-/disable-org-sync=) is removed. (Supersedes the setters half of v1 decision 8; subsumes the "make org-sync private" task.) +2. Org-native editing only where the construct is faithful and editable: priority (cookie A/B/C/D, setter deleted) and state's common case (keyword cycling — gated on keyword derivation, decision 8). State keeps a picker for the slug-collision edge and as the always-available path. Labels keep a picker (tags are render-only). Assignee/project keep pickers (no native construct). +3. Id-bearing fields are picker-authoritative; the id is the dirty basis; display-name/tag edits are cosmetic and ignored (refresh/save-success rewrite them). No =name-id-mismatch=. +4. Structured-field conflicts use a new =pearl--resolve-atomic-conflict= (use-local / use-remote, no smerge), separate from the text gate. +5. Each structured field has a synced baseline (=LINEAR-PRIORITY=, =LINEAR-LABEL-IDS-SYNCED=, =LINEAR-STATE-ID-SYNCED=, =LINEAR-ASSIGNEE-ID-SYNCED=); dirty = live ≠ baseline (id/scalar, network-free); a push advances the baseline. +6. Both =save-issue= and =save-all= stay; structured fields fold into the same queue and outcome contract; =save-all= counts them in its prompt. +7. Missing baselines on legacy files → =skipped/missing-property= with a refresh message; no blind push. +8. Keyword derivation is a prerequisite for the state keyword-cycle dirty path (lands before the v2 state work, per the foundations-first order). Picker-id state needs no companion. The keyword-derivation "push on cycle" is replaced by v2's reconcile-at-save; its keyword→id map is consumed by the state saver. +9. Save-success display rewrite source: use-local → the picker's cached display; use-remote → the display from the conflict fetch. The mutation result (=id updatedAt= only) is not relied on for relation display. + +* Dependencies / sequencing + +Most of v2 stands alone on the picker/id substrate; the state *keyword-cycle* path is the one piece with a hard prerequisite. Ordered foundations-first (as agreed): + +1. [[file:todo-keywords-from-workflow-states-spec.org][keyword derivation]] (READY) — *prerequisite for state keyword-cycle editing.* It makes a fresh fetch render every state as its faithful slug keyword and declares them in =#+TODO:=. Without it, the static-mapping fallback renders unmapped states as =TODO=, so the keyword-vs-name dirty rule would false-positive on a clean fetch (round-2 HP2). It lands before the v2 state work. *Cross-spec reconciliation:* the keyword-derivation spec was written assuming a cycle pushes state immediately (via =pearl--process-heading-at-point=). Under v2 that immediate push is gone — a cycle marks dirty and reconciles at save. So keyword derivation contributes the faithful keyword rendering + the keyword→state-id reverse map; v2's state saver consumes that map at save time. The "push on cycle" half of keyword derivation is replaced by v2's reconcile-at-save, not implemented twice. +2. *This spec's core* — fold structured fields into the engine, convert/rename/delete setters, remove the org-sync push paths. Priority is cookie-native; labels and assignee are picker/id-based; state is picker-id-based always, plus keyword-cycle editing once (1) is in place. The priority/labels/assignee/engine/removal work has no companion dependency and can land independently of (1). +3. [[file:labels-as-org-tags-spec.org][labels-as-org-tags]] (READY) — *display* upgrade only (tags reflect labels). Not an editing path; label editing stays on the picker until a separate bidirectional-tag spec (vNext). + +* Files touched + +- =pearl.el=: =pearl--issue-dirty-fields= (structured scanners); per-field atomic savers + =pearl--run-atomic-field-save=; =pearl--resolve-atomic-conflict=; the save-outcome =:field= / =:reason= enums; render + merge writing the new =-SYNCED= baselines; the setters converted/renamed/deleted; both org-sync hooks unwired and the push machinery removed (=pearl-sync-org-to-linear=, =-current-heading-to-linear=, =pearl-org-hook-function=, =pearl-enable-/disable-org-sync=); =save-all= prompt/summary counts. +- =tests/=: =test-pearl-sync-hooks.el= updated — the removed sync functions/hooks; the new "buffer save pushes nothing" assertions. +- =docs/=: this spec. +- =README.org=: the unified edit model — which affordance edits which field, that every change reconciles at save, and the renamed commands. + +* Test plan + +New cases in =tests/test-pearl-save.el= (keymap/menu files for the renames): + +*Dirty scan (structured, network-free)* +- Clean fetched issue → nothing dirty (live = baseline for all). +- Cookie changed vs =LINEAR-PRIORITY= → priority dirty; Low (=[#D]=) round-trips and is distinct from None. +- State picker chose any id ≠ =LINEAR-STATE-ID-SYNCED= → dirty (no keyword faithfulness needed); collision-second id pushes that exact id. +- With keyword derivation present: a fresh fetch of a non-static team state ("Dev Review") renders its slug keyword and is *not* dirty; cycling the keyword so its slug ≠ =LINEAR-STATE-NAME= → state dirty. +- Without keyword derivation: keyword changes are not scanned as state edits (state is picker-id-only); a fresh fetch of a state that falls back to =TODO= is *not* dirty. +- Label id set ≠ =LINEAR-LABEL-IDS-SYNCED= → dirty; reordered labels → not dirty (order-insensitive). +- Assignee picker id ≠ =-SYNCED= → dirty; hand-edited display name with matching id → not dirty (cosmetic). + +*Per-field save (HTTP stubbed)* +- Clean field → no fetch/update. +- Remote unmoved → clean push, baseline advances. +- Remote moved to the same value as live → =unchanged=, baseline advances, no push. +- Remote moved differently → =conflict= via the atomic gate; use-local pushes; use-remote adopts remote and advances baseline; no smerge offered. +- State collision: picker chose the second id; save pushes that exact id, not the keyword's first-by-position. +- State keyword edit: saver resolves the keyword to its id, pushes, advances baseline. +- Push failure → live + baseline untouched, =failed/push-failed=. +- Missing baseline (legacy file) → =skipped/missing-property=, no push; title/description on the same issue still save. + +*Integration* +- =save-issue= dirty in title + state + assignee pushes all three through the queue, one summary. +- =save-all= prompt names each structured-field count; declining does no structured fetch or mutation. + +*Org-sync removal (HP1 round 2)* +- =pearl-enable-org-sync= no longer exists / installs no state-pushing hook; the old sync functions are gone. +- Cycling a TODO keyword pushes nothing; the change appears only at the next save. +- Saving the org buffer pushes nothing (the old whole-file =after-save= state sync is removed); only =pearl-save-issue= / =pearl-save-all= push. + +*Renames / deletions* +- =pearl-edit-state= / =pearl-edit-assignee= / =pearl-edit-labels= / =pearl-edit-description= exist as buffer-editors (no network on selection); =pearl-set-*= names are gone; =pearl-set-priority= has no replacement command. + +* Review dispositions + +*Round 1 (Codex, 2026-05-25). Verdict: Not ready.* All findings dispositioned; the two open forks resolved by Craig. + +- *HP1 (state live/baseline collapse) — accepted.* Adopted the separate-slot model: baseline =LINEAR-STATE-ID-SYNCED= (+ =LINEAR-STATE-NAME= for slug compare); live = explicit picker =LINEAR-STATE-ID= when set, else the keyword (slug-compared at scan, id resolved in the saver). Render/picker/scan/save-success/test sections rewritten to match. +- *HP2 (labels vs render-only companion) — accepted; fork resolved.* v2 does *not* supersede labels-as-tags. Labels stay picker/id-based like a relation; tags remain render-only display. Bidirectional tag editing is vNext. +- *HP3 (interim label live id) — accepted.* The picker writes =LINEAR-LABEL-IDS= (live) + =LINEAR-LABEL-IDS-SYNCED= (baseline); =LINEAR-LABELS= stays display-only. Dirty is an order-insensitive id-set compare, network-free. +- *HP4 (priority 1:1 vs lossy) — accepted; factual correction.* The code already supports =[#D]= (=org-priority-lowest= =?D=). Mapping settled: None→none, Urgent/High/Medium/Low→A/B/C/D. The lossy three-cookie idea is dropped. +- *HP5 (name-id-mismatch contradiction) — accepted; fork resolved.* Display-name/tag edits are cosmetic and ignored; refresh/save-success rewrite them from the id. =name-id-mismatch= dropped. A non-dirty field runs no saver, so there was nothing coherent to report. +- *MP1 (atomic conflict helper) — accepted.* Added =pearl--resolve-atomic-conflict= with its own contract; the text gate is left for free-text fields. +- *MP2 (save-all counts) — accepted.* Prompt/summary count each structured field; example wording added. +- *MP3 (rename surface) — accepted.* Old command names removed (pre-release), README/menu/keymap/tests/docstrings updated. +- *MP4 (legacy migration) — accepted.* Missing baseline → =skipped/missing-property= with a refresh message; no blind push. +- Test-strategy additions (collision push, keyword-edit, order-insensitive labels, Low/None, missing baseline, save-all counts) folded into the test plan. + +*Round 2 (Codex, 2026-05-25). Verdict: Not ready.* Both blocking and both medium findings accepted. + +- *HP1 (after-save hook still pushes) — accepted.* I'd only named the =org-after-todo-state-change= hook; the =after-save-hook= whole-file sync is a second immediate state push. v2 now unwires both hooks and removes the whole push machinery (new "Org-sync removal" section), subsuming the "make org-sync private" task. Auto-push-on-save is deferred to its own task, built on the engine. +- *HP2 (state dirty depends on keyword derivation) — accepted; sequencing corrected.* The "no dependency" claim was wrong for the keyword-cycle path: the static-mapping =TODO= fallback would make a clean fetch look dirty. Keyword derivation is now a stated prerequisite for keyword-cycle state editing (consistent with the agreed foundations-first order), with picker-id-only state as the no-companion fallback. The cross-spec tension (keyword derivation assumed push-on-cycle; v2 reconciles at save) is reconciled in Dependencies. +- *MP1 (priority numeric shorthand) — accepted.* Fixed the garbled "0/2/3/4 plus Urgent" to the correct None/Urgent/High/Medium/Low ↔ 0/1/2/3/4. +- *MP2 (display-rewrite source) — accepted.* Named the source: use-local → picker's cached display; use-remote → the conflict-fetch display. The mutation result (=id updatedAt=) isn't relied on for relation display. + +* Out of scope / vNext + +- Bidirectional org-tag label editing (a real tag→label save model: unknown-tag handling, label creation/refusal, collision, manual-deletion semantics). v2 keeps labels on the picker. +- Auto-save on buffer save (its own task, builds on this no-op/conflict detection). +- Project/cycle/estimate pickers — same picker pattern, added after assignee proves the shape. +- Richer priority UI if org cookies prove insufficient long-term. |
