2026 10 22 family sync - xopherdeep/do-it-for-the-xp GitHub Wiki

Family Sync (multiplayer part 3) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Every family device (Game Master phones and tablets, kid phones) plays the whole game on one shared save held by the WordPress site, with a hero playing on one device at a time, behind XP Pro.

Architecture: The site (xophz-compass-xp, xp-family/v1) keeps the family's save as keyed records with a per-family change number, and a per-hero play turn that makes one device the only writer of that hero's records. The app captures every store write through DbStorageApi, uploads changed records from an outbox, downloads records after its last number and applies them into the same Ionic stores the game reads. Claims and verdicts stay events (Part 1); the device holding a hero's turn applies verdicts and pays.

Tech Stack: PHP 8 (WordPress REST, wpdb, dbDelta, Gatekeeper), TypeScript, Vue 3 + Ionic 7 + Pinia, @ionic/storage, jest, Cypress 8, the plugin's Node test harness (docker exec u-wordpress php).

Spec: docs/superpowers/specs/2026-10-22-family-sync-design.md (docs commit 17ece7e). Part 1's spec and src/lib/services/multiplayer/FAMILY_EVENTS.md are its background.

Global Constraints

  • Chemical X only: chemx typecheck, chemx lint, chemx test tests/unit/Foo.spec.ts, chemx q, chemx read. Never raw pnpm test, pnpm jest, pnpm typecheck, pnpm lint, and never the whole suite locally; the PR's Tests check is the gate (CLAUDE.md).
  • The app checkout is shared: work on branch feat/family-sync in a git worktree of do-it-for-the-xp (never switch the shared checkout); lock files with chemx team lock before editing them; commit only your own paths (git commit -- <paths>), never git add -A or git stash. Open the PR with auto-merge (gh pr merge <n> --auto --squash). Task 1 alone goes straight to master as a path-scoped commit.
  • The plugin lives in a separate repo: /home/xopher/www/x/Xophz-COMPASS/wp-content/plugins/xophz-compass-xp (branch main); work on branch feat/family-sync there, in a worktree too. Its tests run with node tests/run-tests.mjs from the plugin folder and need the local Docker WordPress (u-wordpress) up. The release to live is the owner's (GitHub release, COMPASS updater): never touch the live site.
  • Copy: every line a person reads is a clerk line through the reveal prompt (useRevealPrompt().showPrompt), never an Ionic alert or a native alert; toasts only with rpgToastOptions. Adventure voice in src/lib/services/copy/catalog/, other voices fall back. No em dashes anywhere (the audit and the plugin tests both refuse them). Exact lines are in spec section 5.
  • Code rules the audit ratchet enforces: a compound boolean gets a name before it is used (const isHeld = ...), at most 5 direct use*() calls per function, no new hooks in defineComponent pages beyond that, pure rules in src/lib/services/** with a jest spec beside the feature in tests/unit/.
  • Phone first: Cypress runs at 390x844; controls stay at the bottom of the screen; the bottom nav bars are never changed.
  • multiplayerEnabled() (src/lib/services/multiplayer/multiplayerFlag.ts) stays the master switch; with it off nothing here runs.
  • Record limits, verbatim from the spec: 50 records and 1 MB per put; 512 KB per record body; GET /records limit 100 by default, 200 at most; 5 pages per download tick; uploads 5 s after the last change.

Review Focus

  1. A record whose body is over 512 KB (a profile with a large face image): the server refuses it with xp_family_record_invalid and the device drops the key instead of resending it forever (Task 3, Task 12).
  2. Pro lapses while a kid phone is in the full portal: the next site call answers 403 xp_family_pro_required, the sync store turns itself off, and the next route change lands on the board with sync.proRequired (Task 14, Task 22).
  3. Two Game Master devices settle the same claim within a minute: the second one finds the entry already settled, or the turn busy, and nothing is paid twice (Task 18).
  4. A tombstone for the signed-in hero's own profile arrives (the hero was deleted on another device): the profile is removed, the user store reloads and signs out instead of crashing on a missing profile (Task 13, Task 14).
  5. A kid phone's first open with Pro while offline: nothing can be pulled, so the phone shows the board with sync.offline and tries again on online; the portal opens only once firstPullDone (Task 20).

File structure

Plugin (xophz-compass-xp)

File Responsibility
includes/family/class-xophz-compass-xp-family-schema.php (modify) xp_family_records, records_seq on families, the turn columns on heroes, schema VERSION 2, tombstone cleanup
includes/family/class-xophz-compass-xp-family-rules.php (modify) POLICY: the new actions per caller
includes/family/class-xophz-compass-xp-family-record-rules.php (create) Pure: the synced stores with scope and hero-id rule, record validation, who may write and read a record
includes/family/class-xophz-compass-xp-family-event-rules.php (modify) synced on quest.claimed
includes/family/class-xophz-compass-xp-family-store.php (modify) upsert_records under the family lock, records_after, take_turn, rest_turn, turns cleared on revoke/remove
includes/family/class-xophz-compass-xp-family-pro.php (create) entitled( $family_id, $now ) through Gatekeeper, with the xp_family_pro_entitled filter
includes/family/class-xophz-compass-xp-family-records.php (create) put, read, play, rest: the handlers' logic over the store, as Family_Events is for events
includes/family/class-xophz-compass-xp-family-events.php (modify) The home rule relaxed for entitled families
includes/family/class-xophz-compass-xp-family-rest.php (modify) The four routes
includes/class-xophz-compass-xp.php (modify) require_once the three new files
tests/family-records.test.mjs, tests/family-turn.test.mjs, tests/family-pro.test.mjs (create); tests/family-schema.test.mjs, tests/family-event-rules.test.mjs, tests/run-tests.mjs (modify) Tests
tests/rehearsal/family-smoke.sh (modify) A records section

App (do-it-for-the-xp)

File Responsibility
src/lib/services/sync/recordSignals.ts (create) recordWritten and storeRestored signals
src/lib/databases/DbStorageApi.ts (modify) Emits recordWritten on set, remove, clear
src/lib/services/core/BackupService.ts (modify) Emits storeRestored per store with the keys a restore removed
src/lib/services/sync/syncScopes.ts (create) The synced stores, scopes, hero id per record, what a phone may write
src/lib/databases/SyncDb.ts (create) __xp_sync state
src/lib/services/sync/familySyncClient.ts (create) The four routes through townHallFetch
src/lib/services/multiplayer/familyServer.ts (modify) pro-required in FamilyProblem
src/lib/services/sync/recordOutbox.ts (create) The queue of changed keys, batching, settling a put
src/lib/services/sync/recordApply.ts (create) Writing received records into the raw stores, mirrors
src/lib/services/sync/heroTurn.ts (create) The turn state machine
src/lib/services/sync/syncPrompts.ts (create) The clerk prompts (busy, took over, first pull, Pro required, offline) and agoOf
src/lib/services/copy/catalog/sync.ts (create), catalog/index.ts (modify) The lines
src/lib/services/sync/verdictApply.ts (create) Applying a claim.settled once
src/lib/store/stores/sync.ts (create) useSyncStore: on/off, upload, download, first push and pull, turns, verdicts, the device label
src/lib/store/stores/family.ts (modify) canQueue and the home question for Pro families; phone claims taken by the turn holder; loadPhones on the tick
src/lib/services/quests/approvalFlow.ts (modify) The remote verdict branch
src/lib/services/multiplayer/phoneClaimIntake.ts, familyEvents.ts (modify) synced on claims; synced claims skipped
src/lib/services/notifications/questWatch.ts (modify) Clocks only for held heroes
src/router/_Guards/heroPhone.guard.ts, index.ts (modify) The guard lifts for a full portal
src/app/SideMenu/HeroPhone/HeroPhone.ts/.vue, components/HeroPhoneBoard.vue (modify) First pull, Pro footer, the portal hand-off
src/app/SideMenu/SwitchProfile/SwitchProfile.vue, src/app/SideMenu/XpSettings/components/XpSettingsHome/settingsSections.ts, src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue (modify) Kid mode in the full portal
src/app/Admin/XpApprovals/XpApprovals.vue (modify) sync.verdictSent
src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue (modify) Task 1's poll
tests/unit/*.spec.ts, tests/e2e/specs/family-sync.spec.js, tests/e2e/fixtures/family/*.json Tests

Phase 0: the free sheet fix (master)

Task 1: A freshly linked phone gets its sheet without a restart

Files:

  • Modify: src/lib/store/stores/family.ts (the listen() interval, around line 790)
  • Modify: src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue (createCode, onMounted, onUnmounted)
  • Test: tests/unit/familyStore.spec.ts, tests/unit/XpHeroPhonesModal.spec.ts

Interfaces:

  • Consumes: useFamilyStore().loadPhones(), queueSheets(), download() (existing).

  • Produces: nothing new.

  • Step 1: Write the failing tests

In familyStore.spec.ts: it("the periodic tick reads the phone list before the sheet pass"): advance fake timers by 120 000 ms after init() on a bound home device; assert server.listPhones was called once more than before and that queueSheets ran after it (spy order).

In XpHeroPhonesModal.spec.ts: it("polls the phone list every 10 s while a code is live and runs the sheet pass when a phone appears"): mount with a mocked store whose phones grows from 0 to 1 on the second loadPhones; advance 10 000 ms twice; assert loadPhones called 3 times (mount + 2 polls) and queueSheets once.

  • Step 2: Run them to see them fail

Run: chemx test tests/unit/familyStore.spec.ts -t "periodic tick" and chemx test tests/unit/XpHeroPhonesModal.spec.ts -t "polls the phone list" Expected: FAIL (listPhones not called on the tick; no polling).

  • Step 3: Implement

In listen(): the interval becomes download().then(() => loadPhones()).then(() => queueSheets()). In the modal: const PHONES_POLL_MS = 10_000; createCode starts phonesTimer = setInterval(pollPhones, PHONES_POLL_MS) where pollPhones compares family.phones.length before and after family.loadPhones() and calls family.queueSheets() when it grew; onUnmounted clears it.

  • Step 4: Run the two specs, then chemx lint

Expected: PASS, no lint errors.

  • Step 5: Commit to master (path-scoped)
git commit -m "fix(multiplayer): the family device re-reads its phone list on the tick and while a link code is live, so a new phone gets its sheet" -- src/lib/store/stores/family.ts src/app/SideMenu/XpSettings/components/XpHeroPhonesModal.vue tests/unit/familyStore.spec.ts tests/unit/XpHeroPhonesModal.spec.ts

Phase 1: the server (plugin branch feat/family-sync)

Task 2: Schema: records, the counter, the turn columns

Files:

  • Modify: includes/family/class-xophz-compass-xp-family-schema.php (VERSION, tables(), cleanup())
  • Test: tests/family-schema.test.mjs

Interfaces:

  • Produces: table {prefix}xp_family_records with the columns of spec section 2 (seq bigint unsigned NOT NULL AUTO_INCREMENT is not used: seq bigint unsigned NOT NULL set by the store), unique key family_store_key (family_id, store, record_key), keys family_seq (family_id, seq) and family_hero_seq (family_id, hero_id, seq); records_seq bigint unsigned NOT NULL DEFAULT 0 on xp_families; turn_device_id varchar(16) NULL, turn_label varchar(60) NOT NULL DEFAULT '', turn_taken_at datetime NULL, turn_seen_at datetime NULL on xp_family_heroes. VERSION = '2'. const TOMBSTONE_DAYS = 90.

  • Step 1: Write the failing tests in family-schema.test.mjs: it("version 2 declares six tables, records with its three keys") (count the CREATE TABLE statements from tables(), assert the records statement contains UNIQUE KEY family_store_key, KEY family_seq, KEY family_hero_seq, body mediumtext); it("families carry records_seq and heroes the four turn columns"); it("cleanup deletes tombstones older than 90 days") (the fake wpdb records a DELETE FROM wp_5_xp_family_records WHERE deleted = 1 AND saved_at < %s query).

  • Step 2: Run node tests/run-tests.mjs from the plugin folder. Expected: the three new tests FAIL.

  • Step 3: Implement the statements in tables() (dbDelta form: one column per line, two spaces before KEY), bump VERSION, add the tombstone delete to cleanup().

  • Step 4: Run the tests. Expected: PASS, and lintPhpFile clean.

  • Step 5: Commit feat(family): records table, the records counter and the hero turn columns (schema 2).

Task 3: Record rules and the synced claim field

Files:

  • Create: includes/family/class-xophz-compass-xp-family-record-rules.php
  • Modify: includes/family/class-xophz-compass-xp-family-rules.php (POLICY), includes/family/class-xophz-compass-xp-family-event-rules.php (quest.claimed fields), includes/class-xophz-compass-xp.php (require)
  • Test: tests/family-records.test.mjs (create), tests/family-event-rules.test.mjs, tests/family-rules.test.mjs

Interfaces:

  • Produces, class Xophz_Compass_Xp_Family_Record_Rules (pure, no WordPress calls):

    • const STORES = the spec's table: store name => [ 'scope' => 'family'|'family-any'|'hero', 'hero_from' => 'key'|'key_prefix'|'userId'|'heroId'|null ].
    • const MAX_BODY_BYTES = 524288; MAX_BATCH = 50; MAX_BATCH_BYTES = 1048576; MAX_KEY = 191;
    • public static function check( array $raw, array $caller_ctx ): array|string returns the clean record [ 'store', 'key', 'hero_id', 'deleted', 'body' ] or a refusal code (xp_family_store_unknown, xp_family_record_invalid, xp_family_scope). $caller_ctx = [ 'writer' => 'parent'|'hero', 'key_hero_id' => string|null ].
    • public static function hero_id_of( string $store, string $key, $body ): ?string.
    • public static function may_read( array $row, array $caller_ctx ): bool.
  • POLICY gains put_records, read_records, play, rest for parent and hero.

  • quest.claimed fields gain 'synced' => 'bool'.

  • Step 1: Write the failing tests. family-records.test.mjs, suite "Record rules": it("knows the 17 stores and their scopes"); it("derives the hero id per store") (profiles → key; templeProgress h-1:temple-x → h-1; questLog body {userId:"h-2"} → h-2; latePenalties body {heroId:"h-3"} → h-3; notifications → key; achievements → null); it("refuses an unknown store, a key over 191 chars, a body over 512 KB, a body that is not an object"); it("a hero key may write only __chat and its own hero's records") (own profile accepted; another hero's questLog → xp_family_scope; __achievements → xp_family_scope; __chat accepted); it("a parent may write every store"); it("may_read: a hero key reads family stores and its own hero's rows only"). In family-event-rules.test.mjs: it("quest.claimed keeps synced as a bool"). In family-rules.test.mjs: it("parent and hero may put_records, read_records, play and rest; none may not").

  • Step 2: Run the harness. Expected: the new tests FAIL (class missing).

  • Step 3: Implement the class and the two table edits; add the require_once in the loader before the store's.

  • Step 4: Run the harness. Expected: PASS.

  • Step 5: Commit feat(family): record rules (stores, scopes, hero id, size caps) and synced on quest.claimed.

Task 4: Store: upsert under the lock, read after, the turn

Files:

  • Modify: includes/family/class-xophz-compass-xp-family-store.php
  • Test: tests/family-records.test.mjs (suite "Store"), tests/family-turn.test.mjs (create)

Interfaces:

  • Produces, on Xophz_Compass_Xp_Family_Store:

    • public function upsert_records( string $family_id, string $device_id, array $records, int $now ): array|WP_Error → [ [ 'store', 'key', 'seq' ], ... ] in input order; WP_Error xp_family_busy (503) when the lock is not had. Under GET_LOCK("xp_family_{id}"): SELECT records_seq FROM xp_families ... FOR UPDATE, number the records counter+1 ..., INSERT ... ON DUPLICATE KEY UPDATE seq=VALUES(seq), hero_id=VALUES(hero_id), device_id=VALUES(device_id), saved_at=VALUES(saved_at), deleted=VALUES(deleted), body=VALUES(body), UPDATE xp_families SET records_seq = counter+n.
    • public function records_after( string $family_id, int $since, int $limit, ?string $hero_id = null ): array → rows ordered by seq; with $hero_id, rows where hero_id IS NULL OR hero_id = %s (the hero key's view).
    • public function take_turn( string $family_id, string $hero_id, string $device_id, string $label, bool $force, int $now ): array → [ 'answer' => 'granted' ] or [ 'answer' => 'busy', 'device' => [ 'label', 'last_seen_at' ] ] (ISO).
    • public function rest_turn( string $family_id, string $hero_id, string $device_id ): void.
    • public function touch_turn( string $family_id, string $hero_id, string $device_id, int $now ): void (refreshes turn_seen_at when this device holds it).
    • public function turn_holder( string $family_id, string $hero_id ): ?string (device id).
    • revoke_device and remove_hero also clear a turn the device or hero held.
  • Step 1: Write the failing tests against the fake wpdb (the pattern of family-two-phone-events.test.mjs's in-memory stand-in): "Store": it("numbers a batch from the family counter and writes it back"); it("an existing key is overwritten and gets the new number"); it("records_after returns rows after since in order, with a hero filter"); "Turn": it("granted when free, busy with the holder's label and last seen, granted with force, same device re-takes silently"); it("rest from a non-holder changes nothing"); it("revoking the holding device clears the turn").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement the methods (the append_events lock pattern; db_time()/iso() helpers for dates).

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(family): the store writes records under the family lock and keeps each hero's turn.

Task 5: The Pro gate and the relaxed home rule

Files:

  • Create: includes/family/class-xophz-compass-xp-family-pro.php
  • Modify: includes/family/class-xophz-compass-xp-family-events.php (send(), the home check), includes/class-xophz-compass-xp.php (require)
  • Test: tests/family-pro.test.mjs (create), tests/family-two-phone-events.test.mjs

Interfaces:

  • Produces: Xophz_Compass_Xp_Family_Pro::entitled( string $family_id, int $now ): bool: apply_filters( 'xp_family_pro_entitled', $default, $family_id ) where $default is class_exists( 'Gatekeeper_Xp_Pro_Licenses' ) && ( $r = Gatekeeper_Xp_Pro_Licenses::get( $family_id ) ) && Gatekeeper_Xp_Pro_Licenses::is_entitled( $r, $now ); cached per request in a static array.

  • Family_Events::send skips the home check when Family_Pro::entitled( $family_id, $now ).

  • Step 1: Write the failing tests. family-pro.test.mjs: it("false without Gatekeeper, true with an active record, false past grace") (stub the Gatekeeper class in the snippet); it("the filter can force it on and off"). In family-two-phone-events.test.mjs: it("an entitled family may send from a device that is not home") (stub Family_Pro::entitled true via the filter; a non-home parent batch is accepted).

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(family): the Pro gate through Gatekeeper, and every parent device may send for an entitled family.

Task 6: The four routes

Files:

  • Create: includes/family/class-xophz-compass-xp-family-records.php
  • Modify: includes/family/class-xophz-compass-xp-family-rest.php (register_routes, four handlers), includes/class-xophz-compass-xp.php
  • Test: tests/family-records.test.mjs (suite "Routes"), tests/family-turn.test.mjs

Interfaces:

  • Produces, Xophz_Compass_Xp_Family_Records (static, over the store):

    • put( $store, array $family, array $caller, string $device_id, $records, int $now ): array|WP_Error → [ 'accepted' => [...], 'refused' => [...] ] per spec; refuses the whole request with xp_family_pro_required (403) when not entitled, xp_family_record_invalid (400) when $records is not a list or exceeds 50 / 1 MB; per record applies Record_Rules::check, then for hero-scope records xp_family_turn unless turn_holder() === $device_id; touches the turn for each hero written.
    • read( $store, array $family, array $caller, int $since, int $limit ): array|WP_Error → wire rows { seq, store, key, hero_id, device_id, saved_at, deleted, body }; touches the turn of the caller's held heroes is not done here (only turn_seen_at of heroes this device holds: call touch_turn for each hero whose turn_device_id is the caller's device, found with one query).
    • play( $store, $family, $caller, string $hero_id, string $device_id, string $label, bool $force, int $now ): array|WP_Error (a hero key only for its own hero).
    • rest( $store, $family, $caller, string $hero_id, string $device_id ): array|WP_Error → [ 'answer' => 'rested' ].
  • REST: PUT /records, GET /records, POST /heroes/{hero_id}/play, POST /heroes/{hero_id}/rest, guarded by put_records, read_records, play, rest.

  • Step 1: Write the failing tests ("Routes", through the classes with the fake store, as the events tests do): it("put refuses an unentitled family with 403 xp_family_pro_required"); it("put answers accepted and refused per record and carries on after a refusal"); it("a hero record from a non-holder is refused with xp_family_turn"); it("read filters by the hero key's hero"); it("play and rest answer as the spec's table, and a hero key cannot play another hero").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement the class and the handlers (copy the shape of send_events/read_events).

  • Step 4: Run the whole harness. Expected: all PASS.

  • Step 5: Commit feat(family): PUT/GET records and the hero's play and rest routes.

Task 7: Rehearsal smoke, changelog, plugin PR

Files:

  • Modify: tests/rehearsal/family-smoke.sh (a records section), CHANGELOG.md or the readme's changelog section (whichever the repo keeps), the plugin version per its convention.

  • Step 1: Add the records section: with the filter forced on (wp eval "add_filter('xp_family_pro_entitled','__return_true');" is not persistent, so set wp option update xp_family_pro_override 1 and read that option in Family_Pro::entitled as a rehearsal-only override that only applies when WP_DEBUG is true): put 3 records as the parent (one hero record without the turn → refused xp_family_turn), play as the parent device, put again (accepted), read since 0 as the parent and as a hero key (the hero sees only its own), play from a second device (busy), play with force (granted), rest.

  • Step 2: Run tests/rehearsal/family-smoke.sh records against the rehearsal network (tools/townhall-rehearsal). Expected: every line PASS.

  • Step 3: Commit, push, open the PR on the plugin repo with the summary of Tasks 2 to 7 and the smoke output; leave the release to the owner (note it in the PR body).


Phase 2: the app's sync core (app worktree, branch feat/family-sync)

Task 8: Record signals from the storage layer

Files:

  • Create: src/lib/services/sync/recordSignals.ts
  • Modify: src/lib/databases/DbStorageApi.ts, src/lib/services/core/BackupService.ts (restore, after each store is written)
  • Test: tests/unit/recordSignals.spec.ts (create), tests/unit/BackupService.spec.ts

Interfaces:

  • Produces: interface RecordWrite { store: string; key: string; value: unknown | null }; onRecordWritten(fn: (w: RecordWrite) => void): () => void; emitRecordWritten(w: RecordWrite): void; interface StoreRestored { store: string; removedKeys: string[] }; onStoreRestored, emitStoreRestored (the same tiny emitter gameSignals.ts builds its signals with). DbStorageApi.set/remove emit after the write with storage.config?.name ?? ""; clear reads keys() first and emits one removal per key.

  • Step 1: Write the failing tests. recordSignals.spec.ts: it("set emits the store name, key and value; remove emits null; clear emits a removal per key") with a DbStorageApi over an in-memory Storage fake (the one BackupService.spec.ts uses). BackupService.spec.ts: it("a replace restore emits storeRestored with the keys the file did not carry") (store had a, b; file has a → removedKeys: ["b"]); it("a merge restore emits none removed").

  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run both specs. Expected: PASS.

  • Step 5: Commit feat(sync): the storage layer announces every record write; a restore announces the keys it removed.

Task 9: Sync scopes

Files:

  • Create: src/lib/services/sync/syncScopes.ts
  • Test: tests/unit/syncScopes.spec.ts

Interfaces:

  • Produces: type SyncScope = "family" | "family-any" | "hero"; interface SyncStoreRule { store: string; scope: SyncScope; heroIdOf?: (key: string, value: unknown) => string | null }; const SYNC_STORES: readonly SyncStoreRule[] (the spec's table, in BACKUP_STORES order, without __localSettings); isSyncedStore(store): boolean; scopeOf(store): SyncScope | null; heroIdOf(store, key, value): string | null; mayPhoneWrite(store, heroId: string | null, ownHeroId: string): boolean (true for family-any, and for hero when heroId === ownHeroId).

  • Step 1: Write the failing tests: it("lists every backup store but Settings, with the spec's scopes") (compare against BACKUP_STORES.map(s => s.name) minus __localSettings); it("derives the hero id per store") (the same six cases as Task 3); it("mayPhoneWrite") (chat yes, own profile yes, other hero no, achievements no).

  • Step 2: Run chemx test tests/unit/syncScopes.spec.ts. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): the synced stores, their scopes and each record's hero.

Task 10: SyncDb

Files:

  • Create: src/lib/databases/SyncDb.ts
  • Test: tests/unit/SyncDb.spec.ts, tests/unit/BackupService.spec.ts (the "not in BACKUP_STORES" check)

Interfaces:

  • Produces: syncStorage (__xp_sync); interface OutboxItem { store: string; key: string; queuedAt: string }; interface SyncState { outbox: OutboxItem[]; lastSeq: number; firstPushDone: boolean; firstPullDone: boolean; turns: Record<string, "held" | "resting">; verdictsSent: Record<string, { approved: boolean; at: string }>; pendingVerdicts: Record<string, ClaimSettled & { heroId: string; seq: number }> }; emptySyncState(): SyncState; readSyncState(stored: unknown): SyncState (tolerant, as readPhoneState); class SyncDb extends DbStorageApi { getState(); saveState(s); clearState() }; getSyncDb(): SyncDb.

  • Step 1: Write the failing tests: it("reads a missing or partial state as empty"); it("round-trips a state"); in BackupService.spec.ts: it("__xp_sync is not a backup store").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement (the shape of HeroPhoneDb).

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): SyncDb keeps the outbox, the last number, the turns and the verdicts sent.

Task 11: The sync client

Files:

  • Create: src/lib/services/sync/familySyncClient.ts
  • Modify: src/lib/services/multiplayer/familyServer.ts (FamilyProblem gains "pro-required"; PROBLEM_BY_SITE_CODE.xp_family_pro_required)
  • Test: tests/unit/familySyncClient.spec.ts, tests/unit/familyServer.spec.ts

Interfaces:

  • Produces: interface WireRecord { store: string; key: string; heroId: string | null; deleted: boolean; body?: unknown }; interface ReceivedRecord extends WireRecord { seq: number; deviceId: string; savedAt: string }; interface PutResult { accepted: { store: string; key: string; seq: number }[]; refused: { store: string; key: string; code: string; message: string }[] }; type PlayAnswer = { answer: "granted" } | { answer: "busy"; device: { label: string; lastSeenAt: string | null } }; putRecords(auth: TownHallAuth, deviceId: string, records: WireRecord[]): Promise<PutResult>; fetchRecords(auth, since: number, limit = 100): Promise<ReceivedRecord[]>; playHero(auth, heroId, deviceId, label, force = false): Promise<PlayAnswer>; restHero(auth, heroId, deviceId): Promise<void>; MAX_RECORD_BATCH = 50, MAX_BATCH_BYTES = 1_000_000, MAX_RECORD_BYTES = 524_288. Wire conversion snake_case ↔ camelCase here only (hero_id, device_id, saved_at, last_seen_at).

  • Step 1: Write the failing tests with a fetchImpl fake (the pattern of familyServer.spec.ts): it("putRecords sends snake_case and reads accepted and refused"); it("fetchRecords passes since and limit and converts rows"); it("playHero reads granted and busy"); it("a 403 xp_family_pro_required reads as pro-required").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run both specs. Expected: PASS.

  • Step 5: Commit feat(sync): the records and turn routes as a client.

Task 12: The record outbox

Files:

  • Create: src/lib/services/sync/recordOutbox.ts
  • Test: tests/unit/recordOutbox.spec.ts

Interfaces:

  • Produces: enqueueKey(outbox: OutboxItem[], store, key, now: Date): OutboxItem[] (one item per store+key; a repeat moves it to the end with the new time); dropItems(outbox, items: { store; key }[]): OutboxItem[]; dropWhere(outbox, pred: (item) => boolean): OutboxItem[]; interface BatchRecord extends WireRecord { item: OutboxItem; bytes: number }; nextBatch(outbox, read: (item) => Promise<BatchRecord | null>, limits = { count: 50, bytes: 1_000_000, record: 524_288 }): Promise<{ records: BatchRecord[]; oversize: OutboxItem[] }> (a record over the per-record cap is returned in oversize and left out); settlePut(outbox, sent: BatchRecord[], result: PutResult): { outbox: OutboxItem[]; takenOver: string[] } (accepted and every refusal drop the item; xp_family_turn refusals also list their hero ids in takenOver, deduped).

  • Step 1: Write the failing tests: it("enqueueKey keeps one item per key and moves a repeat to the end"); it("nextBatch stops at 50 records or 1 MB and sets oversize aside"); it("settlePut drops accepted and refused and names the heroes taken over"); it("a key refused as invalid is not queued again by settlePut").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): the outbox of changed records.

Task 13: Applying records

Files:

  • Create: src/lib/services/sync/recordApply.ts
  • Test: tests/unit/recordApply.spec.ts

Interfaces:

  • Produces: interface ApplyTarget { set(key: string, value: unknown): Promise<void>; remove(key: string): Promise<void> }; rawTargetFor(store: string): ApplyTarget | null (the BACKUP_STORES entry's raw Ionic storage, so DbStorageApi never sees the write and nothing echoes); applyRecords(records: ReceivedRecord[], ownDeviceId: string, targetFor = rawTargetFor): Promise<ApplyOutcome> with interface ApplyOutcome { applied: number; skipped: number; stores: string[]; heroes: string[]; removedProfiles: string[] } (own-device records skipped; unknown stores skipped; a tombstone removes; a failing write is logged and skipped); refreshMirrors(outcome: ApplyOutcome): Promise<void>: when stores has __profiles, StorageService.saveProfiles(all profiles) then useUserStore().loadUsers(); when removedProfiles has the signed-in hero, logoutUser() first.

  • Step 1: Write the failing tests with in-memory targets: it("writes records by key and removes tombstones"); it("skips this device's own records and unknown stores"); it("a failing write is skipped and counted"); it("refreshMirrors reloads users for profiles and signs out a removed current hero") (mock the user store).

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): received records land in the raw stores and the user store reloads.

Task 14: The sync store

Files:

  • Create: src/lib/store/stores/sync.ts
  • Modify: src/lib/store/stores/family.ts (canQueue, runBind's home question, init starts the sync store), src/main.ts or wherever useFamilyStore().init() first runs (no change if family.init starts it)
  • Test: tests/unit/syncStore.spec.ts, tests/unit/familyStore.spec.ts

Interfaces:

  • Consumes: Tasks 8 to 13.

  • Produces, useSyncStore(): isOn: ComputedRef<boolean> (multiplayerEnabled() && useProStore().isPro && (family.parentAuth !== null && family.home !== "unknown" || family.isKidMode)); isFamilyDevice (on, parent auth); isFullPortal (on, kid mode, firstPullDone); state: Ref<SyncState>; deviceLabel: ComputedRef<string> (kid: family.device.label; parent: the family {tablet|phone|computer} from Capacitor.getPlatform() and window.innerWidth >= 768); init(): Promise<void> (loads state, listens to onRecordWritten/onStoreRestored, the 5 s upload timer, the 60 s tick shared with the family store's download, online, visibilitychange; first push on a family device when !firstPushDone; first pull on a kid phone when !firstPullDone); flush(): Promise<void> (upload then download); download(): Promise<void> (5 pages, applyRecords + refreshMirrors, saves lastSeq per page); upload(): Promise<void>; firstPullProgress: Ref<number>; problem: Ref<FamilyProblem | null>; turns, holds(heroId), signIn, takeOver, rest, withTurn, sendVerdict (Task 17 and 18 fill these; declare the names now as no-ops that Task 17 replaces).

  • family.ts: canQueue() becomes enabled.value && (home.value === "home" || useSyncStore().isFamilyDevice); runBind skips the home question (setHome("home") on home-elsewhere) when useSyncStore().isOn; init() calls useSyncStore().init() after listen() and after a kid phone's phone.load().

  • A 403 pro-required from any sync call sets problem = "pro-required", calls useProStore().recheck(), and the store stays off until isPro is true again.

  • A kid phone that is unlinked (family.forgetDevice, also reached from the 401 revoked() path) clears SyncDb (getSyncDb().clearState()) and the turns; the family store calls useSyncStore().reset() from forgetDevice.

  • Step 1: Write the failing tests (syncStore.spec.ts, mocks as familyStore.spec.ts): it("off without Pro, off with the flag off, on for a bound parent and for a linked kid phone"); it("a record write queues its key and uploads 5 s later with the hero id"); it("download applies pages in order and saves lastSeq after each"); it("the first push sends every record of every synced store once"); it("the first pull downloads from 0 and sets firstPullDone"); it("a restore re-sends everything and tombstones the removed keys"); it("pro-required turns the store off and rechecks the licence"); it("forgetDevice clears the sync state"). familyStore.spec.ts: it("a Pro family device queues events although it is not home, and is not asked the home question").

  • Step 2: Run both specs. Expected: FAIL.

  • Step 3: Implement (setup store, the shape of useFamilyStore; one send at a time; onScopeDispose stops timers).

  • Step 4: Run both specs, then chemx typecheck and chemx lint. Expected: PASS, clean.

  • Step 5: Commit feat(sync): the sync store uploads changed records, downloads the family's save and runs the first push and pull.

Task 15: The lines

Files:

  • Create: src/lib/services/copy/catalog/sync.ts
  • Modify: src/lib/services/copy/catalog/index.ts
  • Test: the catalogue spec that checks unique keys (find it with chemx q CATALOG_PARTS), plus tests/unit/syncCopy.spec.ts

Interfaces:

  • Produces: keys sync.busy, sync.tookOver, sync.firstPull, sync.firstPush, sync.proRequired, sync.offline, sync.verdictSent, sync.verdictRejectedSent, sync.playHere ("Play here"), sync.notNow ("Not now"), sync.thisPhone ("This is {nick}'s phone"), with the adventure lines of spec section 5 verbatim.

  • Step 1: Write the failing test: it("every sync line exists in the adventure voice and has no em dash").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement and add sync to CATALOG_PARTS and CATALOG.

  • Step 4: Run the two specs. Expected: PASS.

  • Step 5: Commit feat(sync): the clerk's sync lines.


Phase 3: the turn and the verdicts

Task 16: The turn state machine

Files:

  • Create: src/lib/services/sync/heroTurn.ts
  • Test: tests/unit/heroTurn.spec.ts

Interfaces:

  • Produces: type TurnState = "resting" | "asking" | "held" | "busy" | "takenOver"; interface TurnBusy { label: string; lastSeenAt: string | null }; interface HeroTurn { state: TurnState; busy: TurnBusy | null; provisional: boolean }; type TurnEvent = { type: "signIn" } | { type: "granted" } | { type: "busy"; device: TurnBusy } | { type: "force" } | { type: "decline" } | { type: "signOut" } | { type: "offline" } | { type: "refused" } | { type: "caughtUp" }; initialTurn(): HeroTurn; turnReducer(turn: HeroTurn, e: TurnEvent): HeroTurn (the table in spec section 5; offline while asking → held with provisional: true; refused from held → takenOver; caughtUp → resting); maySend(turn): boolean (held, provisional or not).

  • Step 1: Write the failing tests: one it per row of the spec's table, plus it("an unknown transition leaves the turn unchanged").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): the hero's turn as a state machine.

Task 17: Taking and resting the turn in the app

Files:

  • Create: src/lib/services/sync/syncPrompts.ts
  • Modify: src/lib/store/stores/sync.ts (signIn, takeOver, rest, withTurn, the watchers), src/app/SideMenu/SwitchProfile/SwitchProfile.vue or the sign-in path it uses (the busy prompt after a pick), src/app/Console/MyPortal/MyPortal.ts (the took-over line when turns[current].state === "takenOver")
  • Test: tests/unit/syncPrompts.spec.ts, tests/unit/syncStore.spec.ts

Interfaces:

  • Produces, syncPrompts.ts: agoOf(iso: string | null, now: Date): string ("just now", "{n} min ago", "{n} h ago", "yesterday", "{n} days ago"); busyPrompt(nick, device: TurnBusy, now, voice = "adventure"): RevealPromptOptions (speaker "Town Clerk", choices play = sync.playHere tone primary, later = sync.notNow, cancelId: "later"); tookOverPrompt(nick, label); firstPullPrompt(count); proRequiredPrompt(); offlinePrompt().

  • Sync store: signIn(heroId): Promise<"granted" | "busy" | "offline"> (reducer signIn, playHero(auth, heroId, deviceId, deviceLabel), then granted/busy/offline; on granted download()); takeOver(heroId) (reducer force, playHero(..., true)); rest(heroId) (upload, restHero, reducer signOut); withTurn(heroId, fn: () => Promise<void>): Promise<boolean> (free → take, run, rest; busy → false); a watcher on useUserStore().currentUser.id rests the old hero and signs in the new; visibilitychange hidden rests the current hero, visible signs in again; online re-asks a provisional turn. upload() sends a hero's records only while maySend(turns[heroId]); settlePut's takenOver heroes get reducer refused, then download() and reducer caughtUp, and the took-over line is shown once on the portal.

  • The busy answer on a family device: the profile picker shows busyPrompt; play → takeOver; later → stays on the picker (sign-out). On a kid phone: play → takeOver; later → the board, read-only.

  • Step 1: Write the failing tests: syncPrompts.spec.ts: it("agoOf") (5 cases); it("busyPrompt names the device and the time, cancel is later"). syncStore.spec.ts: it("signIn asks the site and holds on granted, downloads at once"); it("busy keeps the hero out until play here forces it"); it("offline holds provisionally and re-asks on online"); it("rest uploads what waits then tells the site"); it("a record refused with xp_family_turn marks the hero taken over, downloads and rests"); it("hero records are not sent while the hero is resting"); it("withTurn takes, runs, rests, and answers false when busy").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement (prompts through useRevealPrompt().showPrompt, as hometownPrompts.ts and its screens do).

  • Step 4: Run the specs, chemx typecheck, chemx lint. Expected: PASS, clean.

  • Step 5: Commit feat(sync): a hero is taken and rested with sign-in, switch, Save & Quit and the background; the busy and took-over lines.

Task 18: Verdicts and claims under one writer

Files:

  • Create: src/lib/services/sync/verdictApply.ts
  • Modify: src/lib/services/quests/approvalFlow.ts (approveClaim, rejectClaim), src/lib/store/stores/sync.ts (sendVerdict, applying claim.settled and reward.granted from the family store's download), src/lib/store/stores/family.ts (takePhoneClaims through useSyncStore().withTurn on a Pro device; claim.settled and reward.granted handed to the sync store when it is on), src/lib/services/multiplayer/familyEvents.ts (synced field on quest.claimed; questClaimedEvent gains synced?: boolean), src/lib/services/multiplayer/phoneClaimIntake.ts (isPhoneClaim false when data.synced === true), src/app/Admin/XpApprovals/XpApprovals.vue (an entry in verdictsSent reads sync.verdictSent / sync.verdictRejectedSent and its buttons are hidden)
  • Test: tests/unit/verdictApply.spec.ts, tests/unit/approvalFlow.spec.ts (exists? else create), tests/unit/phoneClaims.spec.ts or phoneClaimIntake.spec.ts, tests/unit/familyEvents.spec.ts, tests/unit/syncStore.spec.ts

Interfaces:

  • Produces: applyVerdict(settled: ClaimSettled, deps: { entry(id: string): Promise<QuestLogEntry | null>; approve(id: string): Promise<unknown>; reject(id: string, note: string): Promise<unknown> }): Promise<"applied" | "already" | "missing"> (claimId is the entry id; already when the entry's status is not pending; pending status in settled means nothing to apply → already).

  • approvalFlow: before approveCompletion, const sync = useSyncStore(); const isRemote = sync.isOn && !sync.holds(entry.userId); → when remote: await sync.sendVerdict({ claimId: entryId, questId, status: "approved", note: "", xp: 0, gp: 0, ap: 0 }, entry.userId) (the pay is computed by the holder), emitQuestReviewed as today, return { ok: true, entry, line: "", remote: true } (ReviewOutcome gains remote?: boolean). Same for reject with the note.

  • sync.sendVerdict(settled, heroId): queues claimSettledEvent through the family store's queueApprovalEvent, records verdictsSent[claimId], then withTurn(heroId, () => applyVerdict(...)) (pays when nobody holds the hero; when busy, the holder applies it).

  • Applying on the holder: in the sync store's download of events (the family store hands over claim.settled and reward.granted for heroes this device holds, plus those with no holder through withTurn): applyVerdict with approveCompletion/rejectCompletion; a missing verdict is kept in pendingVerdicts and retried after each records download; verdictsSent[claimId] is dropped when the entry arrives settled.

  • questClaimedEvent(..., { synced: true }) from QuestCompletionService through the family store when useSyncStore().isOn; isPhoneClaim returns false for data.synced === true.

  • The takeover re-send (spec section 5): when settlePut names a hero taken over, the sync store reads each refused __questLog record of that hero whose status is pending and queues a fresh questClaimedEvent(heroId, deviceId, { questId, difficulty, requiresApproval: true }) without synced, through the family store's queueApprovalEvent; the holder takes it as a phone claim. recordOutbox.settlePut therefore also returns refusedRecords: BatchRecord[] so the store can read their bodies.

  • Step 1: Write the failing tests: verdictApply.spec.ts: it("applies an approval once; a repeat answers already"); it("a rejection carries the note"); it("missing when the entry is not here yet"). familyEvents.spec.ts: it("quest.claimed keeps synced"). phoneClaims/intake spec: it("a synced claim is not taken as a phone claim"). approvalFlow spec: it("a verdict for a hero this device does not hold is sent, not written"); it("a verdict for a held hero is written as before"). syncStore.spec.ts: it("sendVerdict pays through withTurn when nobody holds the hero"); it("a verdict for an entry not yet here waits in pendingVerdicts and applies after the download"); it("two devices settling one claim pay once") (the second finds already); it("a pending claim refused in a takeover is re-sent as an unsynced claim event").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run the specs, chemx typecheck, chemx lint. Expected: PASS, clean.

  • Step 5: Commit feat(sync): a verdict is applied and paid by the device holding the hero; synced claims are not taken twice.

Task 19: Clocks only for held heroes

Files:

  • Modify: src/lib/services/notifications/questWatch.ts (around line 132, chargeLatePenalties, and the deadline and encounter checks in the same pass)
  • Test: tests/unit/questWatch.spec.ts (exists? else create)

Interfaces:

  • Consumes: useSyncStore().isOn, holds(heroId).

  • Produces: heroesToWatch(allHeroIds: string[], sync: { isOn: boolean; holds(id: string): boolean }): string[] (pure, exported from questWatch.ts: every hero when sync is off; held heroes when on).

  • Step 1: Write the failing test: it("with sync on, only held heroes are charged and watched"); it("with sync off, every hero is").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): late penalties, deadlines and encounters run only for heroes this device holds.


Phase 4: kid phones as the full portal

Task 20: The guard lifts; the first pull opens the portal

Files:

  • Modify: src/router/_Guards/heroPhone.guard.ts (handleHeroPhoneGuard(to, isKidMode, isFullPortal)), src/router/_Guards/index.ts, src/app/SideMenu/HeroPhone/HeroPhone.ts/.vue (the first pull: sync.firstPullPrompt progress line, then loginUser(hero) and router.replace("/console/my-portal") once isFullPortal; offline: the board with sync.offline)
  • Test: tests/unit/heroPhoneGuard.spec.ts, tests/unit/HeroPhone.spec.ts

Interfaces:

  • Produces: handleHeroPhoneGuard(to, isKidMode: boolean, isFullPortal: boolean): with isFullPortal every route passes except the Game Master routes (gm.guard handles those already).

  • Step 1: Write the failing tests: guard: it("a full portal passes every route"); it("kid mode without the portal still redirects"). HeroPhone: it("shows the first pull line while pulling, then signs the hero in and opens the portal"); it("offline on the first pull shows the board with the offline line").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run both specs. Expected: PASS.

  • Step 5: Commit feat(sync): a Pro kid phone pulls the family's world and opens the full portal.

Task 21: Kid mode inside the portal

Files:

  • Modify: src/app/SideMenu/SwitchProfile/SwitchProfile.vue (kid mode: only the linked hero, sync.thisPhone line, no restore door), src/app/SideMenu/XpSettings/components/XpSettingsHome/settingsSections.ts (sectionsFor({ isKidMode }): without family, packs, general's backup/restore, account; with a device item Unlink this phone → family.forgetDevice() after sync.rest and getSyncDb().clearState()), src/app/SideMenu/XpSettings/components/XpFamilyPhones.vue (hidden in kid mode), src/components/molecules/Modals/SaveAndQuitModal.vue or its handler (on a kid phone, Save & Quit rests and returns to /hero-phone)
  • Test: tests/unit/settingsSections.spec.ts (create), tests/unit/SwitchProfile.spec.ts (exists? else create)

Interfaces:

  • Produces: sectionsFor(opts: { isKidMode: boolean }): SettingsSection[] (pure, exported beside SETTINGS_SECTIONS).

  • Step 1: Write the failing tests: it("kid mode hides family, packs, general backup and account, and shows Unlink this phone"); it("the picker in kid mode shows the one hero and no restore door").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run the specs, chemx lint. Expected: PASS, clean.

  • Step 5: Commit feat(sync): kid mode inside the portal: one hero, no Game Master bits, Unlink this phone.

Task 22: The Pro fallback on a kid phone

Files:

  • Modify: src/app/SideMenu/HeroPhone/components/HeroPhoneBoard.vue (footer sync.proRequired when family.isKidMode && !useProStore().isPro), src/router/_Guards/heroPhone.guard.ts (already: no portal without Pro)

  • Test: tests/unit/HeroPhone.spec.ts

  • Step 1: Write the failing test: it("a kid phone without Pro shows the board with the Pro line and the guard keeps it there").

  • Step 2: Run. Expected: FAIL.

  • Step 3: Implement.

  • Step 4: Run. Expected: PASS.

  • Step 5: Commit feat(sync): without Pro a kid phone keeps the board and says why.


Phase 5: rehearsal and release

Task 23: Cypress, the rehearsal run, the PR

Files:

  • Create: tests/e2e/specs/family-sync.spec.js, fixtures tests/e2e/fixtures/family/records-page.json, play-granted.json, play-busy.json, pro-required.json

  • Modify: docs/Roadmap.md (the multiplayer section: part 3 built), src/lib/services/multiplayer/FAMILY_EVENTS.md (the synced field; a pointer to the spec for records and turns)

  • Step 1: Write the Cypress spec (390x844, cy.intercept on ${LIVE_SITE}/** as family.spec.js): "a kid phone with Pro pulls the world and opens the portal"; "the busy line offers Play here and Not now"; "the took-over line shows once after a refused record"; "without Pro the board shows the Pro line".

  • Step 2: Run pnpm vue-cli-service test:e2e --headless --browser chrome --url http://localhost:8100 --spec tests/e2e/specs/family-sync.spec.js against the shared dev server (curl -s localhost:8100 answers; never start a second one). Expected: 4 passing.

  • Step 3: The rehearsal run on the local Docker WordPress with the plugin branch installed: two browser profiles as parent devices (two Town Hall links, same account) and one as a kid phone; walk spec section 12's list (play on the tablet then the phone; approve from the second parent while the hero plays and while nobody does; force a takeover with one device offline; lapse Pro through the filter). Record each step's outcome in the PR body.

  • Step 4: chemx typecheck, chemx lint, then push feat/family-sync and open the PR with auto-merge (gh pr merge <n> --auto --squash); bind it with the ccd_pr tools and read the Tests check. Fix and push until green.

  • Step 5: Hand the owner the release checklist in the final message: merge the plugin PR and cut its release (the COMPASS updater delivers it to doit), confirm the APK rebuild and upload, and that app.doit.forthexp.com deploys on the merge.

⚠️ **GitHub.com Fallback** ⚠️