# Phase 35: Psyche Sync — Cross-Machine Context Backup via Private gh Repo — Research

**Researched:** 2026-05-24 (refresh — supersedes the 2026-05-22 draft)
**Domain:** Rust subprocess plumbing over `git` + `gh` CLI; `$SPT_HOME/settings.json` persistence; SessionStart `hookSpecificOutput.additionalContext` injection; detached fire-and-forget pull on UserPromptSubmit; doctor surfaces
**Confidence:** HIGH — every primitive anchors on shipped Phase 18.3/18.4/23/24/24.1/25/25.4/28 code that has been verified at current line numbers below.

## User Constraints

(Reproduced verbatim from `35-CONTEXT.md`. All D-decisions are LOCKED — research treats them as non-negotiable.)

### Locked Decisions (from CONTEXT.md)

- **D-01:** Pull strategy = `git pull --rebase` + `-X theirs` (file-level last-write-wins).
- **D-02 (revised 2026-05-22):** `sessions.log` IS synced like every other tracked file; D-01 last-write-wins handles conflicts. Original gitignore plan was incompatible with shipped Phase 24 staging behavior (`commit_agent_payload` at `src/common/tracked.rs:1210` explicitly stages `sessions.log` via the seal pipeline at line 1304 — `git add` with explicit paths does not honor `.gitignore`).
- **D-03:** Defensive `git rebase --abort` + `git stash --include-untracked` on every cycle entry; doctor counter `recovered-N-aborts`.
- **D-04:** UserPromptSubmit = fire-and-forget async pull; post-commune/signoff commit = synchronous pull-then-push.
- **D-05:** No client-side throttling on UserPromptSubmit; fire every prompt; detached subprocess.
- **D-06:** Scope = `a-{self_id}` + (if resolves) `p-{cwd_project}`; no hot-path `--all`.
- **D-07:** 30s subprocess timeout on gh/git sync ops (extends Phase 23 D-13's 500ms standard).
- **D-08:** Auto-detect on every `$LIVE start` gated by state (`unset` OR `remind-later` past cooldown); queues `AskUserQuestion` via SessionStart `additionalContext` (Phase 28 / Phase 33 AUTO-03 precedent).
- **D-09:** `AskUserQuestion` options = Yes / No (never) / Remind later (12h).
- **D-10:** Accept flow = `gh repo create --private` (browser fallback on scope error) → per-worktree `git remote add origin` → `git push --all origin` (one-time only) → persist settings.json `sync.state = enabled`. Future Phase 24 D-16 lazy worktrees inherit the remote.
- **D-11:** Settings.json schema — nested `sync` namespace under `$SPT_HOME/settings.json`, enum `state ∈ {unset, enabled, declined, remind-later, failing}`, ISO-8601 UTC timestamps.
- **D-12:** `git push --all` ONLY during initial seeding; runtime is per-branch.
- **D-13:** `/psyche-sync-setup` unified front door; idempotent (`enabled` → status; `declined` → re-prompt allowed).
- **D-14:** Hot-path silent; doctor + `SPT_TRACE=1` stderr trace are the only surfaces (Phase 24 D-02 + Phase 18.7.1 D-04 inheritance).
- **D-15:** Doctor table per-branch row format under a new `## Sync` section; global row at top with state + remote_url.
- **D-16:** Runtime auth failures collapse to ordinary git failures (no special `auth-failing` state); `gh` is setup-only.
- **D-17:** 404 on push/fetch = hard-stop `failing` state with `last_failure_reason = "remote-404"`; doctor instructs `/psyche-sync-setup` re-run.
- **D-18:** Exponential backoff for transient failures — 1m → 5m → 15m → 1h → 6h → 24h capped; gate not timer; reset on success.
- **D-19:** `failing` state is a hard stop, not a backoff state; D-17 + explicit user disable route into it.

### Claude's Discretion (from CONTEXT.md)

- Exact `AskUserQuestion` framing strings on auto-detect vs manual paths — researcher recommendation in Open Questions §4.
- `gh auth setup-git` invocation site — **researcher answer: ONCE globally** during accept_flow step 2, NOT per-worktree (rationale in Pattern 6).
- Backoff delay schedule tunable defaults — researcher confirms D-18's 1m/5m/15m/1h/6h/24h is reasonable; GitHub authenticated rate limit is 5000 req/hr, so worst-case rapid retries are well under.
- Subprocess detachment mechanism — researcher answer: **reuse `crate::common::win_spawn::spawn_detached_no_inherit` on Windows** (`src/common/win_spawn.rs:222`). No equivalent Unix helper exists today (grep confirms — see Open Questions §1). Phase 35 must add `crate::common::process::spawn_detached_unix` mirroring the Windows helper's signature via `setsid` + null stdio.
- Post-commit sync hook placement — researcher answer: **at the tail of `commit_agent_payload` and `commit_project_payload`** in `src/common/tracked.rs:1210` and `:1241`, AFTER the inner `commit_payload(..)` call returns `Ok(())`, BEFORE the function's own `Ok(())` return (Pattern 4).
- Doctor row collapse rules — researcher recommendation: collapse to one summary row when global state == enabled AND all per-branch rows are clean; expand on failure or under `--verbose`.
- `$LIVE list` sync-state indicator — researcher recommendation: defer (Phase 32 list format is freshly redone; column drift risks regression).

### Deferred Ideas (OUT OF SCOPE — from CONTEXT.md)

- Custom merge driver for sessions.log union semantics (`jsonl-union` via `.gitattributes`).
- `--all` sweep on `$LIVE start` boot (active-only for now).
- Smart pull-coalescing flock on UserPromptSubmit.
- Per-machine UUID identity refinement.
- GitHub Enterprise / self-hosted remote support.
- Repo name customization (locked to `spt-agent-storage`).
- `$LIVE doctor --repair-sync` shortcut.
- Multi-account `gh` support.
- `$LIVE list` sync-state indicator (visual polish only).

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SYNC-BOOTSTRAP-01 | Once-per-machine `$SPT_HOME/settings.json` flag (sync-enabled, sync-acked, remote-repo-url) | D-11 schema locked; new helpers `read_sync_settings` / `write_sync_settings` mirror the read-modify-write pattern that `src/common/auto_setup.rs:11-55` and `src/owl/plugin_session_start.rs::sync_settings_json` apply to `~/.claude/settings.json` — but target the DIFFERENT file `$SPT_HOME/settings.json` (Pitfall 7). |
| SYNC-AUTO-01 | Auto path: boot checks `gh` CLI presence; surfaces `AskUserQuestion` via SessionStart `additionalContext` | D-08 gate fires in `src/live/start.rs::run` and `live_start_result` (both at line 187 / 610) after the existing perch readiness checks. Emission rides on the Phase 28 / Phase 33 `emit_auto_pick` pattern at `src/owl/plugin_session_start.rs:139-155`. Note discrepancy with ROADMAP SC1 — see Pitfall 9. |
| SYNC-MANUAL-01 | Manual `/psyche-sync-setup` skill drives prereq install + repo creation + accept_flow | D-13 unified entry. New skill at `plugin/spt/skills/psyche-sync-setup/SKILL.md` calls `$OWL psyche-sync-setup` subcommand which dispatches into shared `accept_flow`. |
| SYNC-HOOK-01 | Every commit triggers pull → apply local → push cycle; UserPromptSubmit fires async pull | D-04 two-trigger design. Post-commit fires at the tail of `commit_agent_payload` (`src/common/tracked.rs:1210`) and `commit_project_payload` (`:1241`). UserPromptSubmit async pull is dispatched from `src/owl/hook_prompt.rs::run` at line 22, after the existing wake-sentinel / spool-drain branches return, via `win_spawn::spawn_detached_no_inherit`. |
| SYNC-NOOP-01 | Graceful no-op when gh absent, unauthenticated, sync disabled — no errors, no hook block | D-14 silent hot path. `gh`-missing detected by `gh --version` exit ≠ 0; sync-disabled detected by `sync.state != enabled`; auth failure surfaces as ordinary git nonzero per D-16. `SPT_TRACE=1` gates per-event stderr per Phase 18.7.1 D-04. |

## Project Constraints (from CLAUDE.md)

- **Platform:** Windows native + Unix.
- **Portability:** copy-files-and-go install; no runtime deps beyond `owl.exe`. Phase 35 adds `gh` as a **setup-only** prereq — acceptable because runtime sync uses plain `git` (already required by Phase 24).
- **Tech stack:** Rust runtime under `src/`. Deps limited to `clap`, `serde`/`serde_json`, `chrono`, `rusqlite` (bundled), `ctrlc`, `libc`, `windows-sys`. **No new crates needed.**
- **Output conventions:** status → stderr (ANSI-colored: owl cyan, live orange); message body → stdout.
- **Status tags:** existing `READY`/`SENT`/`STOPPED`/`CLEANED` etc. Phase 35 may add `SYNC_OK:branch`, `SYNC_FAIL:branch reason` tags under `SPT_TRACE=1` only.
- **Module conventions:** new module `src/common/sync.rs` mirrors `src/common/tracked.rs`. `snake_case` fns, `PascalCase` types, `pub(crate)` cross-module unless tests need broader visibility.
- **psyche.md is embedded via `include_str!`** — **NOT touched in Phase 35**. (Verified — no psyche.md surface in this phase.)
- **Build/Deploy:** end-of-milestone DEPLOY.ps1 -Bump (NOT per-phase) — Phase 35 lands as part of v1.8 close.
- **Orthogonal hook side effects must NOT gate each other via early returns.** Codified by Phase 25.4 smoke-probe and the `src/owl/hook_idle.rs:62-82` regression fix (the `let _ = version_changelog::maybe_emit_version_change_block` line deliberately drops the original early-return so `set_idle_ready` and `spawn_echo_commune_if_live` still fire). Phase 35's UserPromptSubmit async-pull dispatch lives in `hook_prompt.rs` and MUST follow the same posture — see Pattern 2.
- **GSD workflow:** all edits route through GSD commands.

## Summary

Phase 35 wires Phase 24's bare `psyches/tracked/seed/` to a private GitHub remote and adds two trigger points (async pull on `UserPromptSubmit`, sync pull-then-push on every commune/signoff commit) plus two setup entry points (auto-detect on `$LIVE start` boot, manual `/psyche-sync-setup` skill). The codebase already ships every hard primitive: `crate::common::git::run_git_checked` (Phase 24 subprocess wrapper, zombie-safe, hide-window-on-Windows — `src/common/git.rs:472`), `crate::common::win_spawn::spawn_detached_no_inherit` (`src/common/win_spawn.rs:222`, Phase 18.3/18.4 detached fire-and-forget pattern with `CREATE_BREAKAWAY_FROM_JOB` + `bInheritHandles=FALSE`), `crate::common::owlery::derive_current_repo_names` (`src/common/owlery.rs:763`), and the SessionStart `hookSpecificOutput.additionalContext` envelope (`src/owl/plugin_session_start.rs::emit_auto_pick` lines 139-155). The phase is composition over invention.

The novel surface is small and well-scoped: (a) one new module `src/common/sync.rs` exposing `pull_branch(branch, worktree)` / `push_branch(branch, worktree)` / `accept_flow(remote_url, worktrees)` / `sync_after_commit(branch, worktree)` over a new 30s-budget variant of `run_git_checked` (today only the 500ms `run_git_with_timeout` and the caller-supplied-timeout `run_git_checked` exist — D-07 needs a wrapper that defaults to 30s); (b) two settings.json read/modify/write helpers (`read_sync_settings` / `write_sync_settings`) operating on **`$SPT_HOME/settings.json`** (NOT `~/.claude/settings.json`); (c) one new skill `plugin/spt/skills/psyche-sync-setup/SKILL.md`; (d) one-line additions to `ensure_agent_worktree` (`src/common/tracked.rs:492`) and `ensure_project_worktree` (`:500`) for the "if sync enabled, add origin" extension; (e) extensions to `src/owl/doctor.rs::run` (`:22`), `src/owl/hook_prompt.rs::run` (`:22`), `src/live/start.rs::run` (`:187`), `src/live/start.rs::live_start_result` (`:610`), and `src/owl/plugin_session_start.rs::run` (`:157`).

**Primary recommendation:** Implement sync as a thin facade over the existing Phase 24 subprocess plumbing. Use `run_git_checked(args, None, Duration::from_secs(30))` directly for all sync ops (no new wrapper strictly needed — the timeout argument already exists; just pick the right Duration constant). For UserPromptSubmit fire-and-forget, dispatch a self-invocation `owl.exe sync-pull-async --agent {id} [--project {name}]` via `spawn_detached_no_inherit` on Windows and a new mirror `spawn_detached_unix` (setsid + null stdio fork) on Unix. Pre-rebase recovery (D-03) checks `.git/rebase-merge` and `.git/rebase-apply` directory existence (no subprocess) before firing `git rebase --abort` only when at least one is present. `gh auth setup-git` is invoked **once globally** during accept_flow (D-10 step 2.5), NOT per-worktree — gh sets git's `credential.helper` at the configured scope and that helper applies to every `git push/pull` regardless of worktree.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Setup-time GitHub repo creation | `gh` CLI subprocess | `src/common/sync.rs::accept_flow` | gh handles auth + REST; sync.rs orchestrates the multi-step sequence |
| Runtime auth (push/pull credentials) | git credential helper (gh-installed or SSH agent) | none | D-16: gh is setup-only; runtime is pure git |
| Per-branch pull-then-push | `src/common/sync.rs` | `crate::common::git::run_git_checked` (existing 472:; 30s Duration) | New module sits next to `tracked.rs`, calls into existing subprocess helper with extended timeout |
| Async fire-and-forget pull | `crate::common::win_spawn::spawn_detached_no_inherit` (Windows) + new `crate::common::process::spawn_detached_unix` (Unix) | new owl subcommand `sync-pull-async` | Reuse Phase 18.3/18.4 detachment helper; child re-enters via owl.exe with a detachable subcommand |
| Settings.json (sync namespace) persistence | `src/common/owlery.rs` new helpers `read_sync_settings` / `write_sync_settings` | `serde_json::Value` round-trip | Lives in `$SPT_HOME/settings.json` (NOT `~/.claude/settings.json`); pattern mirrors `auto_setup::compute_settings_update` (read → mutate → atomic-rename write) but on a different file |
| Auto-detect AskUserQuestion injection | `src/owl/plugin_session_start.rs` extension | `hookSpecificOutput.additionalContext` envelope (Phase 28 / 33 AUTO-03) | Add a sibling emit branch alongside `emit_auto_pick` at `:139-155`; SessionStart hook already runs on `$LIVE start` boot path |
| Manual setup driver | `plugin/spt/skills/psyche-sync-setup/SKILL.md` + binary subcommand `psyche-sync-setup` | Skill calls `$OWL psyche-sync-setup` which runs the same `accept_flow` used by auto-detect | Single code path, two entry surfaces |
| Doctor surface | `src/owl/doctor.rs` | reads `$SPT_HOME/settings.json` sync block + per-worktree `git status` | New `check_sync_status()` returning N rows (one global + per-branch). Insertion site is the existing `results.push(..)` chain at `:22-38` |
| Backoff gate | `src/common/sync.rs::is_backoff_active(now)` | settings.json `next_retry_after_ts` | Pure function over (now, settings) — every sync entry point gates through it |

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `gh` CLI (system) | ≥ 2.40 | Repo creation + initial auth + credential helper wiring | Setup-only per D-16; no runtime gh invocation [CITED: cli.github.com/manual/gh_auth_setup-git] |
| `git` CLI (system) | already required by Phase 24 | All runtime sync ops | Phase 24 D-01 already shells to system git; no new dep [VERIFIED: `src/common/git.rs:472` `run_git_checked`] |
| `serde_json` | already in Cargo.toml | settings.json read/modify/write | Existing dep; pattern proven in `src/common/auto_setup.rs` and `src/owl/plugin_session_start.rs::sync_settings_json` |
| `chrono` | already in Cargo.toml | ISO-8601 UTC timestamps | Already used by `src/common/time.rs::now_iso_utc` per Phase 24.1 [VERIFIED] |
| `crate::common::win_spawn::spawn_detached_no_inherit` | in-repo | Windows detached spawn for UserPromptSubmit async pull | Phase 18.4 helper [VERIFIED: `src/common/win_spawn.rs:222`] |
| `crate::common::process::hide_window` | in-repo | Suppress console flash on Windows git subprocess | Already applied in every git call by `run_git_checked` [VERIFIED: `src/common/git.rs:478`] |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `libc::setsid` | already in Cargo.toml (libc dep) | Unix detached spawn | Build a NEW `crate::common::process::spawn_detached_unix` helper using fork + setsid + dup2 to /dev/null. No equivalent helper exists today (Open Question §1, verified by grep) |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `gh repo create` for setup | Direct GitHub REST API call via reqwest/ureq | Adds an HTTP-client dep (forbidden by zero-dep constraint); gh handles auth + scopes invisibly |
| `git pull --rebase -X theirs` | `git fetch + git reset --hard origin/{branch}` | Reset is destructive (drops unpushed local commits — would lose payload writes that hadn't pushed yet). Rebase preserves the commit chain; `-X theirs` deterministically resolves file conflicts per D-01 |
| `git pull --rebase` | `git fetch origin {branch} && git rebase -X theirs origin/{branch}` | **Recommended:** the fetch+rebase form works without upstream-tracking config (which `git push --all` does NOT set — Pitfall 4). Equivalent semantics; more robust on first post-setup sync |
| Per-worktree `gh auth setup-git --hostname github.com` | Single global invocation | Per-worktree adds N subprocess calls for no benefit — gh writes to git's `credential.helper` at process-global scope [CITED: cli.github.com/manual/gh_auth_setup-git] |
| New `run_git_checked_with_timeout` wrapper | Use existing `run_git_checked(args, None, Duration::from_secs(30))` directly | The existing function ALREADY takes a `timeout: Duration` parameter (`src/common/git.rs:472-476`). No wrapper needed; pick the right constant at the call site |

**Installation:** No new Rust crates. System CLI dependencies:
```bash
# Windows
winget install GitHub.cli
# macOS
brew install gh
# Linux
sudo apt install gh   # or per-distro equivalent
```

**Version verification:** Not applicable — no new package installs. Phase consumes system CLIs already conditionally required by the user. Detection via `gh --version` exit status (mirrors Phase 24's `git --version` pattern at `src/common/git.rs:WARNED_GIT_UNAVAILABLE` plumbing).

## Package Legitimacy Audit

**Skipped — no external packages installed.** Phase 35 consumes system CLIs (`gh`, `git`) that the user installs explicitly via the manual skill flow (D-13 step 2). No npm / PyPI / crates.io packages added.

## Architecture Patterns

### System Architecture Diagram

```
UserPromptSubmit                            Commune/Signoff commit
       │                                             │
       │ (synchronous hook)                          │ (synchronous return)
       ▼                                             ▼
  src/owl/hook_prompt::run                  src/common/tracked.rs::
       │                                    commit_agent_payload  (line 1210)
       │  spawn detached                    commit_project_payload (line 1241)
       │  (win_spawn / setsid)                       │
       ▼                                             ▼
  owl.exe sync-pull-async ────┐         src/common/sync::sync_after_commit
       │                      │                     │
       │ (fire-and-forget)    │                     │ (blocking, 30s budget)
       ▼                      │                     ▼
  pull_branch(agent_id)       │              is_backoff_active?
  pull_branch(cwd_project)    │                     │ no
                              │                     ▼
                              │             abort_stale_rebase()
                              │                     │
                              │                     ▼
                              │             pull_branch (fetch + rebase -X theirs)
                              │                     │
                              │                     ▼
                              │             push_branch (origin {branch})
                              │                     │
                              │                     ▼
                              │             on-success: reset failure counter
                              │             on-404:     state = failing (hard)
                              │             on-other:   bump counter; set next_retry_after_ts
                              │
                              └──────► same path as post-commit
                                       (just no push on async pull)

  $LIVE start boot                                  $OWL doctor
  src/live/start.rs::run                            src/owl/doctor.rs::run
       │     (line 187)                                  │ (line 22)
       ▼                                                 ▼
  D-08 auto-detect gate                          check_sync_status():
   - gh --version exit 0?                          - read settings.json sync block
   - sync.state ∈ {unset,                          - per worktree: git status,
       remind-later past cooldown}                   last-ok / last-err / retry-after
       │ yes                                        - global row + per-branch rows
       ▼
  Queue AskUserQuestion for next
  SessionStart hook (Phase 28 / 33 path)
       │
       ▼
  SessionStart emits hookSpecificOutput
  with additionalContext containing
  <spt-psyche-sync-prompt> envelope
       │
       ▼
  User answers Yes/No/Remind-later
       │  Yes
       ▼
  /psyche-sync-setup skill ───► $OWL psyche-sync-setup ──► accept_flow:
                                                             1. gh repo create --private
                                                                (on scope-error → browser fallback)
                                                             2. gh auth setup-git  (ONCE, global)
                                                             3. for each existing worktree:
                                                                  git -C remote add origin {url}
                                                             4. git -C seed push --all origin
                                                                (then per-branch --set-upstream-to)
                                                             5. write settings.json
                                                                sync.state = enabled
```

### Recommended Project Structure
```
src/
├── common/
│   ├── tracked.rs        # extend commit_agent_payload (1210) + commit_project_payload (1241)
│   │                     #   with tail-end `sync::sync_after_commit(branch, worktree)`;
│   │                     # extend ensure_agent_worktree (492) + ensure_project_worktree (500)
│   │                     #   with one-line `if sync.state == enabled then git remote add origin`
│   ├── sync.rs           # NEW: pull_branch, push_branch, accept_flow, sync_after_commit,
│   │                     #   is_backoff_active, next_delay, abort_stale_rebase,
│   │                     #   classify_and_record_outcome, enumerate_existing_worktrees
│   ├── owlery.rs         # extend with read_sync_settings, write_sync_settings,
│   │                     #   sync_settings_path() pointing at $SPT_HOME/settings.json
│   ├── process.rs        # NEW helper: spawn_detached_unix (setsid + null stdio fork) —
│   │                     #   mirrors win_spawn::spawn_detached_no_inherit signature
│   ├── git.rs            # NO CHANGE — existing run_git_checked already takes a
│   │                     #   Duration parameter; pass Duration::from_secs(30) at call site
│   └── win_spawn.rs      # NO CHANGE — reuse spawn_detached_no_inherit (222) as-is
├── owl/
│   ├── hook_prompt.rs    # extend run() (22): after the existing branches, conditionally
│   │                     #   spawn detached `owl.exe sync-pull-async` if sync enabled
│   │                     #   and backoff inactive. MUST NOT early-return (orthogonal hook)
│   ├── plugin_session_start.rs  # add maybe_emit_sync_prompt branch alongside emit_auto_pick
│   │                            #   (current emit at 139-155; new branch slots in around 205)
│   ├── doctor.rs         # extend run() (22): push check_sync_status() into results.
│   │                     # Add check_sync_status() returning rows with state + last-ok/err
│   └── mod.rs            # register Commands::SyncPullAsync and Commands::PsycheSyncSetup
├── live/
│   └── start.rs          # extend run() (187) + live_start_result() (610): gate D-08
│                         #   sync auto-prompt (reads sync settings; queues SessionStart emit)
plugin/spt/skills/
└── psyche-sync-setup/    # NEW skill
    └── SKILL.md
```

### Pattern 1 — Settings.json read/modify/write under `$SPT_HOME`
**What:** Pure helper takes current `serde_json::Value`, returns `Option<Value>` (Some = needs write, None = no drift). Caller does I/O. Mirrors the read-modify-write shape already used for `~/.claude/settings.json` by `src/common/auto_setup.rs:11-55` and `src/owl/plugin_session_start.rs::sync_settings_json` — but writes to a DIFFERENT file (Pitfall 7).
**When to use:** Any sync-state mutation (set state, bump failure counter, etc.).
**Example:**
```rust
// Source: src/common/owlery.rs (new helpers; mirrors auto_setup pattern but on $SPT_HOME)
pub fn sync_settings_path() -> PathBuf {
    spt_home().join("settings.json")           // NOT ~/.claude/settings.json — see Pitfall 7
}

pub fn read_sync_settings() -> SyncSettings { /* parse "sync" object; default on any error */ }
pub fn write_sync_settings(s: &SyncSettings) -> Result<(), std::io::Error> {
    // Read existing file (or empty object), splice/replace "sync" block,
    // pretty-print + atomic-rename. Mirrors compute_settings_update semantics
    // but for the sync namespace under a DIFFERENT file.
}
```

### Pattern 2 — Detached fire-and-forget pull on UserPromptSubmit (reuses Phase 18.3/18.4 helper)
**What:** UserPromptSubmit hook dispatches a self-invocation of `owl.exe sync-pull-async` and returns. The detached child performs the actual git pull. **MUST NOT early-return** — orthogonal-hook side effects principle from Phase 25.4 (verified: `src/owl/hook_idle.rs:62-82` codifies the same posture for version-change emission).
**When to use:** Hot-path triggers that must not block user latency.
**Example:**
```rust
// Source: src/owl/hook_prompt.rs — extension at end of run(), AFTER both branches finish
//         their output. Must NOT return early; this dispatcher is orthogonal.
fn dispatch_async_sync_pull(self_id: &str) {
    let settings = crate::common::owlery::read_sync_settings();
    if settings.state != SyncState::Enabled { return; }
    let now = crate::common::time::now_iso_utc();
    if crate::common::sync::is_backoff_active(&settings, &now) { return; }

    let exe = match std::env::current_exe() { Ok(p) => p, Err(_) => return };
    let projects = crate::common::owlery::derive_current_repo_names();
    let project = projects.first().cloned();

    let mut args: Vec<&str> = vec!["sync-pull-async", "--agent", self_id];
    if let Some(ref p) = project { args.push("--project"); args.push(p); }

    #[cfg(windows)]
    { let _ = crate::common::win_spawn::spawn_detached_no_inherit(&exe, &args, &[]); }
    #[cfg(unix)]
    { let _ = crate::common::process::spawn_detached_unix(&exe, &args); }
}
```

### Pattern 3 — Pre-cycle rebase recovery via directory existence check (no subprocess)
**What:** Before any sync cycle, check `.git/rebase-merge` and `.git/rebase-apply` dirs. If either exists, fire `git rebase --abort`. Otherwise no subprocess — and no `recovered-N-aborts` counter bump.
**When to use:** D-03 defensive recovery at start of every sync cycle.
**Source:** [CITED: git-scm.com/docs/git-rebase — `.git/rebase-merge` (modern) and `.git/rebase-apply` (legacy)]
**Linked-worktree caveat:** Phase 24 worktrees have a `.git` FILE (not dir) pointing to `seed/.git/worktrees/{name}/`. The rebase state for a linked worktree lives under that path. Check BOTH the direct `{worktree}/.git/rebase-merge` (works when `.git` is a directory, i.e. the seed itself) AND the resolved-gitdir variant.

```rust
// Source: src/common/sync.rs (NEW)
fn abort_stale_rebase(worktree: &Path) -> bool {
    let direct_merge = worktree.join(".git").join("rebase-merge");
    let direct_apply = worktree.join(".git").join("rebase-apply");
    let has_state = direct_merge.exists() || direct_apply.exists()
                 || resolved_gitdir_has_rebase(worktree);
    if !has_state { return false; }
    let _ = git::run_git_checked(
        &["-C", &worktree.to_string_lossy(), "rebase", "--abort"],
        None, Duration::from_secs(5));
    true   // caller bumps recovered-N-aborts (D-03)
}
```

### Pattern 4 — Post-commit sync hook placement
**What:** `commit_agent_payload` (`src/common/tracked.rs:1210`) and `commit_project_payload` (`:1241`) fire `sync::sync_after_commit(branch, worktree)` immediately after the inner `commit_payload(..)` Ok-branch, BEFORE returning `Ok(())`. Soft-fail per D-14 — sync failure never blocks delivery; the payload is already on disk + in the commit.
**When to use:** D-04 second trigger (synchronous pull-then-push).
**Example:**
```rust
// Source: src/common/tracked.rs — extension at line 1210
pub fn commit_agent_payload(agent_id: &str, files: &[&str], subject: &str)
    -> Result<(), TrackedError>
{
    commit_agent_payload_with_timeout(agent_id, files, subject,
        Duration::from_millis(PAYLOAD_TIMEOUT_MS))?;

    // Phase 35 D-04 post-commit sync. Soft-fail per D-14.
    let branch = format!("a-{}", agent_id);
    let worktree = crate::common::owlery::agent_worktree_path(agent_id);
    let _ = crate::common::sync::sync_after_commit(&branch, &worktree);
    Ok(())
}
```

### Pattern 5 — `AskUserQuestion` injection via SessionStart `additionalContext`
**What:** Auto-detect path emits a `<spt-psyche-sync-prompt>` envelope in `hookSpecificOutput.additionalContext`. The next user-facing turn renders this and Claude calls `AskUserQuestion` based on its content. Sibling branch alongside `emit_auto_pick` (`src/owl/plugin_session_start.rs:139-155`).
**When to use:** D-08 boot-time auto-detect path.
**Example:**
```rust
// Source: src/owl/plugin_session_start.rs — new branch alongside emit_auto_pick
pub(crate) fn emit_sync_prompt() {
    let context = "<spt-psyche-sync-prompt>\n\
        Cross-machine context sync via private GitHub repo `spt-agent-storage` is\n\
        available. Run /psyche-sync-setup to enable, or accept the prompt below.\n\
        </spt-psyche-sync-prompt>";
    let envelope = serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": context,
        }
    });
    println!("{}", serde_json::to_string(&envelope).unwrap_or_default());
}

// Caller (inside run() at ~line 205, AFTER the existing emit_auto_pick branch):
//   if should_emit_sync_prompt(&read_sync_settings(), &now_iso_utc(), &gh_present()) {
//       emit_sync_prompt();
//       // Bump last_prompted_ts to dedup within boot
//   }
```

### Pattern 6 — Backoff gate as pure predicate, not a timer
**What:** Every sync entry point first calls `is_backoff_active(settings, now) -> bool`. No spontaneous retries; the natural next trigger (next prompt, next commit) re-checks the gate.
**When to use:** Every sync entry point — guarantees no thundering-herd retry on flaky networks.
**Example:**
```rust
// Source: src/common/sync.rs (NEW)
pub fn is_backoff_active(s: &SyncSettings, now_iso: &str) -> bool {
    match s.next_retry_after_ts.as_deref() {
        Some(t) => now_iso < t,    // ISO-8601 sorts lexicographically
        None => false,
    }
}

pub fn next_delay(consecutive_failures: u32) -> Duration {
    match consecutive_failures {        // D-18 schedule
        0 => Duration::from_secs(60),
        1 => Duration::from_secs(60 * 5),
        2 => Duration::from_secs(60 * 15),
        3 => Duration::from_secs(60 * 60),
        4 => Duration::from_secs(60 * 60 * 6),
        _ => Duration::from_secs(60 * 60 * 24),    // capped
    }
}
```

### Anti-Patterns to Avoid

- **Early-return on the UserPromptSubmit sync dispatcher.** The orthogonal-hook side-effects principle (Phase 25.4) forbids it. `hook_prompt.rs::run` already finishes its existing branches with `output_hook_response`; the new sync dispatch is appended AFTER and must not interfere. See `src/owl/hook_idle.rs:62-82` — the version-change emission deliberately drops a prior early-return for exactly this reason.
- **Spontaneous retry timers.** A background thread polling `next_retry_after_ts` adds complexity for no benefit. D-18 chose gate-not-timer because the next natural trigger is sufficient and predictable.
- **`git push --force` or `git reset --hard origin/{branch}` for conflict resolution.** Both destroy local commits silently. D-01 chose `--rebase -X theirs` because rebase preserves the commit chain while still letting the most-recent payload win at file scope.
- **Reading `~/.claude/settings.json` for the sync block.** The sync namespace lives in `$SPT_HOME/settings.json`. Mixing these causes cross-machine drift and pollutes the claude-code-managed file. CONTEXT D-11 and ROADMAP SC3 both lock the location.
- **Per-worktree `gh auth setup-git` calls.** Wasted subprocess work. The credential helper is written at process-global scope and applies to every `git push/pull` regardless of worktree path.
- **Treating `gh` setup-failure as transient.** If `gh repo create` returns "scope insufficient" (HTTP 403 + `missing required scopes`), retrying with no user intervention is pointless. Surface to skill; skill drives `gh auth refresh --scopes repo` or browser fallback.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Detached process spawn on Windows | Custom `CreateProcessW` wrapper | `crate::common::win_spawn::spawn_detached_no_inherit` (`win_spawn.rs:222`) | Phase 18.4 already solved bInheritHandles + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + console-flash |
| git subprocess timeout + kill | Custom Command::output + monitor thread | `crate::common::git::run_git_checked` with `Duration::from_secs(30)` (`git.rs:472`) | Existing function ALREADY takes a `timeout: Duration` parameter — pass 30s instead of inventing a wrapper |
| ISO-8601 UTC timestamps | Custom format strings on chrono::Utc::now() | `crate::common::time::now_iso_utc()` | Phase 24.1 D-01 already standardized; lexicographic comparisons rely on the canonical format |
| Rebase-in-progress detection | `git status --porcelain` parse | Filesystem `.exists()` on `.git/rebase-merge` and `.git/rebase-apply` | No subprocess overhead; canonical detection method |
| Self-coalescing on rapid UserPromptSubmit | flock + pid file + monitor thread | D-05 chose "fire every prompt; let natural idempotency handle it" | Each pull on a given branch is idempotent; D-15 doctor surfaces accumulated state |
| HTTP client for repo creation | `reqwest` / `ureq` + GitHub REST | `gh repo create --private` | gh handles auth, scopes, error parsing; adding HTTP dep violates zero-dep constraint |
| GitHub URL fallback constructor | String-format the user/repo into URL | Literal `https://github.com/new?name=spt-agent-storage&visibility=private` opened via platform `start`/`open`/`xdg-open` | CONTEXT D-10 step 1 specifies this exact URL shape |

**Key insight:** Phase 35 has remarkably few hand-roll temptations because Phase 24's `run_git_checked` + Phase 18.4's `win_spawn` already cover the two hardest problems (subprocess robustness + Windows detachment). The phase is mostly composition. The one genuinely-new helper is the Unix mirror of `spawn_detached_no_inherit` — Plan 35-03 (per ROADMAP plan listing) is dedicated to that.

## Runtime State Inventory

Phase 35 is **additive**, not a rename/refactor. This section catalogues runtime state Phase 35 introduces and pre-existing state it touches.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | `$SPT_HOME/settings.json` new top-level `sync` namespace (D-11 schema). All per-worktree git state in `psyches/tracked/seed/refs/remotes/origin/` (pull/push naturally maintain). | New schema — first-run write happens in `accept_flow` step 4. No existing data to migrate (this is `unset` → `enabled` transition). |
| Live service config | GitHub remote repo `{user}/spt-agent-storage` itself — created by `gh repo create` during accept_flow. Its existence is the load-bearing dependency for SYNC-HOOK-01. | None — explicit creation step. Deletion outside SPT (user manually deletes repo) is exactly D-17's 404 hard-stop scenario. |
| OS-registered state | git credential helper installed by `gh auth setup-git` at git's configured scope. Persists across SPT binary handoffs naturally. | One-time invocation in accept_flow step 2; no per-worktree, no per-boot. |
| Secrets / env vars | GitHub PAT or OAuth token lives in gh's own credential store (`~/.config/gh/hosts.yml` on Unix, equivalent on Windows). SPT never reads or writes the token. | None — gh manages this. |
| Build artifacts | None — phase ships in the same `owl.exe` binary; no new install artifacts. | None. |

**Nothing found in category:** None — every category has a concrete item or is explicitly N/A.

## Common Pitfalls

### Pitfall 1: Detached child on Windows inherits parent's job-object kill-on-close
**What goes wrong:** UserPromptSubmit hook's detached `sync-pull-async` child gets `TerminateProcess` when Claude Code's Bash tool closes its job handle. Sync pull silently dies mid-stream.
**Why it happens:** Default Rust `Command::spawn` on Windows hard-codes `bInheritHandles=TRUE` and doesn't break away from the parent's job.
**How to avoid:** Use `crate::common::win_spawn::spawn_detached_no_inherit` (`win_spawn.rs:222`) — passes `CREATE_BREAKAWAY_FROM_JOB` + `bInheritHandles=FALSE`. Phase 18.4 proven.

### Pitfall 2: `git rebase --abort` returns nonzero when no rebase is in progress
**What goes wrong:** Defensive abort emits a stderr warning (or bumps the `recovered-N-aborts` counter) on every cycle.
**Why it happens:** `git rebase --abort` exit code is nonzero in the absence of state.
**How to avoid:** Pre-check `.git/rebase-merge` and `.git/rebase-apply` directory existence BEFORE firing abort. Only fire when at least one is present; only bump the counter when the abort actually fired.

### Pitfall 3: `git push --all origin` on first-time setup pushes EVERY local branch
**What goes wrong:** Accept-flow step 3 (`git push --all origin`) succeeds and seeds remote with branches the user didn't expect (every agent + project worktree that has ever existed locally).
**Why it happens:** This is exactly D-12's intent — initial seeding is a one-shot full upload. The pitfall is operator confusion if they have stale local branches; mitigated by Phase 24 D-16 lazy creation.
**How to avoid:** Document explicitly in the manual skill: "this will upload all local agent and project context to the remote." Don't add a per-branch confirmation gate.

### Pitfall 4: `git pull --rebase` on a branch with no upstream-tracking returns nonzero
**What goes wrong:** First sync after accept_flow on a freshly-pushed branch fails because no `branch.{name}.merge` config is set.
**Why it happens:** `git push --all origin` does NOT set upstream tracking by default. `-u` is incompatible with `--all`.
**How to avoid:** **Recommended:** in `pull_branch`, use `git fetch origin {branch}` then `git rebase -X theirs origin/{branch}` instead of `git pull --rebase`. The fetch+rebase form works without upstream tracking. Alternative: after `git push --all`, run per-branch `git -C {worktree} branch --set-upstream-to=origin/{branch}` for each known worktree (`enumerate_existing_worktrees` already iterates them in accept_flow).

### Pitfall 5: `gh repo create` with insufficient PAT scope returns nonzero with "missing required scopes [repo]"
**What goes wrong:** Accept-flow step 1 fails because the user authenticated `gh` with a PAT that lacks the `repo` scope (e.g., `public_repo` only).
**Why it happens:** Private repo creation requires the full `repo` scope (or fine-grained PAT with "Administration: write"). [CITED: cli/cli#5798, #6740, #13032]
**How to avoid:** Detect the scope error by matching stderr against `"missing required scopes"` OR `"HTTP 403"`. On detection, fall back to the browser-creation URL. Optionally print: "your gh token lacks `repo` scope — run `gh auth refresh --scopes repo` and re-try, or create the repo manually in the browser tab now opening".

### Pitfall 6: `git push --all` from a worktree dir vs from `seed/`
**What goes wrong:** Operator runs `git push --all origin` from inside an agent worktree directory. Behavior could vary across git versions.
**How to avoid:** Invoke from `seed/` explicitly: `git -C {spt_home}/psyches/tracked/seed push --all origin`. This is the canonical site Phase 24 uses for tagged operations.

### Pitfall 7: Settings.json schema collision with `~/.claude/settings.json`
**What goes wrong:** Reader/writer code accidentally points at `~/.claude/settings.json` instead of `$SPT_HOME/settings.json`. Sync state ends up in the wrong file; lost across `~/.claude/settings.json` rewrites.
**Why it happens:** Both files are named `settings.json`. `auto_setup.rs:16` and `plugin_session_start.rs::sync_settings_json` BOTH operate on `~/.claude/settings.json` — easy to confuse with the new sync helpers.
**How to avoid:** Name new helpers `read_sync_settings` / `write_sync_settings` + `sync_settings_path()` (NOT `read_settings` / `write_settings`). Have `sync_settings_path()` return `crate::common::owlery::spt_home().join("settings.json")`. Add a doc-comment block at the top of each helper explicitly naming the target file.

### Pitfall 8: `gh auth setup-git` fails silently when not authenticated
**What goes wrong:** Accept-flow step 2 (post-create credential helper wiring) emits a setup-git error because `gh auth status` would return nonzero.
**How to avoid:** D-13 step 3 already gates on `gh auth status`. Ordering MUST be: install-check → auth-status-check → auth-login (interactive) → repo-create → setup-git.

### Pitfall 9: Discrepancy between ROADMAP SC1 ("check if repo exists; prompt if found") and CONTEXT D-08 ("prompt if `sync.state ∈ {unset, remind-later past cooldown}`")
**What goes wrong:** Following ROADMAP SC1 literally means the auto-prompt only fires when the repo ALREADY exists — i.e., the user must have created it manually first. CONTEXT D-08 supersedes: prompt fires regardless of repo presence; accept-flow CREATES the repo.
**Why it happens:** CONTEXT is the locked decision per the gsd convention. ROADMAP was drafted earlier. CONTEXT wins.
**How to avoid:** Honor CONTEXT D-08. Do NOT add a `gh repo view {user}/spt-agent-storage` pre-check. The auto-prompt offers the creation flow; accept-flow does the creation.

### Pitfall 10: Phase 25.4 perch resolver — agent / project worktrees, not perches
**Discovered during this re-research:** Phase 25.4 closed-out the perch-path resolver migration (`src/common/perch_path.rs`). The new resolver applies to **perches** (`owlery/<id>/...`), NOT to **tracked worktrees** (`psyches/tracked/agents/<id>/` and `psyches/tracked/projects/<name>/`). Phase 35 sync operates on the tracked worktrees and uses `crate::common::owlery::agent_worktree_path(agent_id)` (`owlery.rs:105`) and `project_worktree_path(project_name)` (`owlery.rs:111`). These resolvers were unchanged by 25.4. **Do NOT route sync ops through `perch_path::resolve_*`** — wrong namespace.

## Code Examples

### Sync subcommand dispatch (new owl subcommands)
```rust
// Source: src/cli.rs (extension to existing Commands enum)
pub enum Commands {
    // ... existing
    /// Phase 35: fire-and-forget pull for UserPromptSubmit detached child.
    SyncPullAsync {
        #[clap(long)] agent: String,
        #[clap(long)] project: Option<String>,
    },
    /// Phase 35: shared accept-flow driver for both auto and manual paths.
    PsycheSyncSetup {
        #[clap(long)] disable: bool,
    },
}
```

### Per-branch pull / push primitives
```rust
// Source: src/common/sync.rs (NEW)
const SYNC_TIMEOUT: Duration = Duration::from_secs(30);   // CONTEXT D-07

pub fn pull_branch(branch: &str, worktree: &Path) -> Result<(), SyncError> {
    // 1. Defensive abort (D-03)
    if abort_stale_rebase(worktree) { bump_recovered_aborts_counter(); }

    // 2. Defensive stash (D-03)
    let _ = git::run_git_checked(
        &["-C", &worktree.to_string_lossy(), "stash", "--include-untracked"],
        None, SYNC_TIMEOUT);

    // 3. Fetch then rebase — Pitfall 4 mitigation
    git::run_git_checked(
        &["-C", &worktree.to_string_lossy(), "fetch", "origin", branch],
        None, SYNC_TIMEOUT)?;
    let result = git::run_git_checked(
        &["-C", &worktree.to_string_lossy(),
          "rebase", "-X", "theirs", &format!("origin/{}", branch)],
        None, SYNC_TIMEOUT);
    classify_and_record_outcome(result, branch);
    Ok(())
}

pub fn push_branch(branch: &str, worktree: &Path) -> Result<(), SyncError> {
    let result = git::run_git_checked(
        &["-C", &worktree.to_string_lossy(), "push", "origin", branch],
        None, SYNC_TIMEOUT);
    classify_and_record_outcome(result, branch);
    Ok(())
}

fn classify_and_record_outcome(r: Result<String, GitError>, branch: &str) {
    let mut s = owlery::read_sync_settings();
    match r {
        Ok(_) => {
            s.consecutive_failures = 0;
            s.last_failure_ts = None;
            s.last_failure_reason = None;
            s.next_retry_after_ts = None;
        }
        Err(GitError::Nonzero { stderr })
            if stderr.contains("Repository not found")
            || stderr.contains("404")
            || stderr.contains("remote: Repository") =>
        {
            s.state = SyncState::Failing;
            s.last_failure_reason = Some("remote-404".to_string());
            s.last_failure_ts = Some(now_iso_utc());
        }
        Err(_) => {
            s.consecutive_failures += 1;
            s.last_failure_ts = Some(now_iso_utc());
            s.next_retry_after_ts = Some(now_plus(next_delay(s.consecutive_failures - 1)));
        }
    }
    let _ = owlery::write_sync_settings(&s);
}
```

### Accept-flow (D-10)
```rust
// Source: src/common/sync.rs
pub fn accept_flow(user: &str) -> Result<(), SyncError> {
    // 1. Create repo
    let create = Command::new("gh")
        .args(["repo", "create", &format!("{}/spt-agent-storage", user),
               "--private",
               "--description", "SPT agent context backup — cross-machine sync"])
        .output()?;
    if !create.status.success() {
        let err = String::from_utf8_lossy(&create.stderr);
        if err.contains("missing required scopes") || err.contains("HTTP 403") {
            return Err(SyncError::ScopeFallbackToBrowser);
        }
        return Err(SyncError::GhFailed(err.to_string()));
    }
    // 2. Credential helper wiring — ONCE, global
    let _ = Command::new("gh").args(["auth", "setup-git"]).output();

    // 3. Wire remote into every existing worktree
    let remote_url = format!("git@github.com:{}/spt-agent-storage.git", user);
    for wt in enumerate_existing_worktrees()? {
        let _ = git::run_git_checked(
            &["-C", &wt.to_string_lossy(), "remote", "add", "origin", &remote_url],
            None, SYNC_TIMEOUT);
    }
    // 4. Initial seed push (D-12)
    let seed = owlery::tracked_dir().join("seed");
    git::run_git_checked(
        &["-C", &seed.to_string_lossy(), "push", "--all", "origin"],
        None, SYNC_TIMEOUT)?;
    // 4b. Per-branch upstream tracking — Pitfall 4
    for branch in list_local_branches(&seed)? {
        let _ = git::run_git_checked(
            &["-C", &seed.to_string_lossy(),
              "branch", &branch, &format!("--set-upstream-to=origin/{}", branch)],
            None, SYNC_TIMEOUT);
    }
    // 5. Persist state
    let mut s = owlery::read_sync_settings();
    s.state = SyncState::Enabled;
    s.remote_url = Some(remote_url);
    s.acked_ts = Some(now_iso_utc());
    owlery::write_sync_settings(&s)?;
    Ok(())
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `rev-parse --abbrev-ref HEAD` for branch detection | `symbolic-ref --short -q HEAD` (returns nonzero on detached HEAD) | Phase 24.1 D-05 (already in tree) | Use same idiom if Phase 35 needs branch-name resolution |
| Custom `CreateProcessW` per call-site | `crate::common::win_spawn` helper | Phase 18.4 (still current) | Phase 35 inherits — no new Win32 FFI needed |
| Direct stderr write on git failure | `SPT_TRACE=1`-gated `trace!` macro | Phase 18.7.1 D-04 | Phase 35 hot path uses this exclusively per D-14 |
| `init.defaultBranch=main` reliance | Explicit `checkout -B main` at init | Phase 24-01 test pattern | Phase 35 sync ops do not init repos; inherits Phase 24's setup |
| Orthogonal hook side effects gated via early-return | Side effects run unconditionally; emission is the only conditional | Phase 25.4 (`hook_idle.rs:62-82` is the codification) | Phase 35 UserPromptSubmit dispatcher inherits this posture — see Pattern 2 + Anti-Patterns |

**Deprecated/outdated:**
- The pre-2026-05-22 plan to gitignore `sessions.log` (folded into the revised D-02 — see CONTEXT.md verbatim above).
- ROADMAP SC1's "check if repo exists; prompt if found" pre-check — superseded by CONTEXT D-08 (see Pitfall 9).

## Validation Architecture

> `.planning/config.json::workflow.nyquist_validation` not explicitly set; treat as enabled.

### Test Framework
| Property | Value |
|----------|-------|
| Framework | `cargo test` (Rust built-in) |
| Config file | `Cargo.toml` |
| Quick run command | `cargo test --lib sync` |
| Full suite command | `cargo test` |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SYNC-BOOTSTRAP-01 | Settings.json sync block round-trips (default → mutate → persist → re-read) | unit | `cargo test --lib owlery::sync_settings` | Wave 0 |
| SYNC-AUTO-01 | `should_emit_sync_prompt` predicate pure-function over (settings, now, gh-present) — fires when unset, fires when remind-later past cooldown, skips otherwise | unit | `cargo test --lib plugin_session_start::sync_prompt` | Wave 0 |
| SYNC-MANUAL-01 | `accept_flow` happy-path against a tempdir fake-seed (mocked gh via PATH override) | integration | `cargo test --test sync_accept_flow` | Wave 0 |
| SYNC-HOOK-01 | post-commit `sync_after_commit` invoked exactly once after `commit_agent_payload` Ok-branch (source-order test via `include_str!` byte-scan — Phase 28/29 precedent) | unit (source-order) | `cargo test --lib tracked::sync_hook_ordering` | Wave 0 |
| SYNC-NOOP-01 | gh-missing path: `read_sync_settings` default → `dispatch_async_sync_pull` short-circuits without subprocess; SPT_TRACE=0 emits no stderr | unit | `cargo test --lib sync::noop_gates` | Wave 0 |
| D-03 recovery | `abort_stale_rebase` returns true iff `.git/rebase-merge` exists in a tempdir simulant | unit | `cargo test --lib sync::abort_stale_rebase` | Wave 0 |
| D-07 timeout | `SYNC_TIMEOUT` constant = 30s (not 500ms Phase 23 default) | unit | `cargo test --lib sync::timeout_constant` | Wave 0 |
| D-18 backoff schedule | `next_delay(0..6)` returns 60 / 300 / 900 / 3600 / 21600 / 86400 (capped) | unit | `cargo test --lib sync::next_delay` | Wave 0 |
| Pitfall 4 missing-upstream | first pull after accept-flow uses fetch+rebase (NOT pull --rebase) so no upstream-tracking error | integration (tempdir bare repo as fake remote) | `cargo test --test sync_no_upstream` | Wave 0 |
| Orthogonal hook | `hook_prompt::run` exits hook_output then dispatches sync — no early return on either branch | unit (source-order or smoke) | `cargo test --lib hook_prompt::orthogonal_sync_dispatch` | Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test --lib` (fast unit subset, < 30s)
- **Per wave merge:** `cargo test`
- **Phase gate:** Full `cargo test` green before `/gsd:verify-work` + manual two-machine UAT against a real `gh` + real GitHub account.

### Wave 0 Gaps
- [ ] `src/common/sync.rs` — module does not exist yet; create with stubs + unit tests for `is_backoff_active`, `next_delay`, `abort_stale_rebase`, `classify_and_record_outcome`.
- [ ] `src/common/owlery.rs` — add `read_sync_settings` / `write_sync_settings` / `sync_settings_path()` with serde round-trip tests.
- [ ] `src/common/process.rs` — add `spawn_detached_unix` mirror of `win_spawn::spawn_detached_no_inherit`. Existing file only has `killpg` helpers (verified — grep returned only lines 142/152).
- [ ] `tests/sync_accept_flow.rs` — new integration test driving `accept_flow` against tempdir fixtures (`gh` mocked via PATH override or trait seam).
- [ ] `tests/sync_no_upstream.rs` — integration test confirming Pitfall 4 mitigation.
- [ ] Source-order pin tests for D-04 post-commit hook site (Phase 28/29 `include_str!` byte-scan precedent).
- [ ] Source-order or smoke test confirming the UserPromptSubmit dispatcher does NOT short-circuit existing branches in `hook_prompt.rs`.

## Security Domain

> `security_enforcement` not explicitly disabled in config; include security review.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Delegated to `gh` CLI's credential store (OAuth token or PAT); SPT never reads tokens directly. `gh auth setup-git` wires git's credential helper to gh — `git push/pull` authenticates transparently. |
| V3 Session Management | n/a | No SPT-side session; gh manages its own. |
| V4 Access Control | yes | Private repo by `gh repo create --private`; access scoped to the authenticated user's account. No org/team flow in this phase. |
| V5 Input Validation | yes | `agent_id` and `project_name` already validated via Phase 24's `validate_id_chars` before being interpolated into branch names. Remote URL constructed from gh-verified output (not user input). Trailer-injection mitigation in `sanitize_trailer_value` (Phase 24 T-24-01-02). |
| V6 Cryptography | n/a | Phase 35 does NOT roll any crypto. Transport is HTTPS via git (TLS) or SSH (OpenSSH); both terminate at GitHub. |

### Known Threat Patterns for Rust + git + gh stack

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Argv injection via `agent_id` / `project_name` into git CLI | Tampering | All git invocations use explicit argv (`Command::args(["-C", path, "remote", "add", ...])`); never `Shell` or `bash -c`. Phase 24 `validate_id_chars` already restricts character set. |
| Path traversal via `agent_id` / `project_name` into worktree path | Tampering | Phase 24 `validate_id_chars` rejects `.` / `/` / `\` — enforced before reaching `tracked.rs` APIs. Sync piggybacks. |
| Credential exposure via `SPT_TRACE=1` stderr | Information Disclosure | `run_git_checked` already routes git's stderr to a captured buffer — only the buffer (NOT live stderr) is conditionally printed under SPT_TRACE. Git's authentication never writes tokens to stderr; tokens are exchanged with the credential helper out of band. |
| Repo squatting (attacker creates `spt-agent-storage` first under user's account) | Spoofing | `gh repo create` against an existing name returns nonzero; accept-flow surfaces error. Repo-name customization deferred. |
| Remote tampering via compromised credential helper | Tampering / Repudiation | Out of scope — same threat as any git-over-https workflow. SPT doesn't add or mitigate; user's gh auth is the trust boundary. |
| Commit-message injection extending into trailer block | Tampering | Phase 24 D-08 `sanitize_trailer_value` strips CR/LF (T-24-01-02). Sync does not re-render trailers — they're sealed in by the producing commit. |

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `gh repo create` exact stderr format for "missing required scopes" — aggregated from cli/cli issue threads, NOT a direct gh source-code check | Pitfall 5 | If gh changes wording, scope-detection regex misses → setup loops. Mitigation: also check for `HTTP 403` literal AND `scopes` substring; any match → fallback. [ASSUMED — based on cli/cli#5798, #6740, #13032] |
| A2 | `git push --all origin` does NOT set per-branch upstream tracking | Pitfall 4 | If wrong, the `--set-upstream-to` follow-up is redundant (harmless). [VERIFIED: git-scm.com/docs/git-push] |
| A3 | No unified Unix detached-spawn helper exists in `crate::common::process` today | Standard Stack / Wave 0 | Grep confirmed: only `killpg` lines 142/152 in `process.rs`. Plan 35-03 adds it. [VERIFIED 2026-05-24] |
| A4 | Rebase-state-dir paths inside linked worktrees may live under `seed/.git/worktrees/{id}/rebase-merge` (not `{worktree}/.git/rebase-merge`) — Phase 24 worktrees use linked-worktree shape | Pattern 3 | If the direct-path check misses, `abort_stale_rebase` returns false. Mitigation: check BOTH direct and gitdir-resolved paths. [ASSUMED — git-scm.com/docs/git-worktree describes linked-worktree gitdir but doesn't explicitly document rebase state location] |
| A5 | GitHub returns HTTP 404 (not 403) for push to a deleted repo even when authenticated | D-17 hard-stop classifier | If GitHub returns 403 in some cases, classifier misses → branch sits in transient backoff forever. Mitigation: classifier matches "404" AND "Repository not found" AND "remote: Repository"; any match → hard-stop. [VERIFIED: GitHub community #52522] |
| A6 | The 5-minute Bash-tool job-object kill is the only Windows process-lifetime trap on UserPromptSubmit hook spawns | Pitfall 1 | If a NEW trap exists, sync pulls die earlier. Mitigation: use `spawn_detached_no_inherit` as-is; Phase 18.4 validated end-to-end. [VERIFIED: src/common/win_spawn.rs:1-34 comment block] |
| A7 | ROADMAP SC1 ("query account; prompt if repo exists") is superseded by CONTEXT D-08 ("prompt if state ∈ {unset, remind-later past cooldown}") | Pitfall 9 | If user actually wants the ROADMAP shape (only prompt when remote already exists), `gh repo create` would never fire from auto-detect path — only from manual skill path. Planner: confirm before locking. [ASSUMED — gsd convention says CONTEXT.md wins; user confirmed in discussion 2026-05-22 per file date] |
| A8 | `psyche.md` is NOT touched by Phase 35 | Project Constraints | `psyche.md` is `include_str!`-embedded; the 2026-05-24 v1.11.14 typed-envelope rewrite is orthogonal to sync. Verified: no sync touchpoint exists in `psyche.md` — Phase 35 surfaces are all in `src/owl/`, `src/common/`, `src/live/start.rs`, and `plugin/spt/skills/psyche-sync-setup/`. [VERIFIED — grep on psyche.md returned no sync-related strings] |
| A9 | Phase 25.4's `perch_path` resolver is NOT relevant to Phase 35 sync ops | Pitfall 10 | Phase 25.4 perch resolver applies to `owlery/<id>/` perches; sync ops target `psyches/tracked/{agents,projects}/<x>/` worktrees which use `agent_worktree_path` / `project_worktree_path`. Mixing these would be a category error. [VERIFIED: src/common/owlery.rs:105,111 and src/common/perch_path.rs presence] |
| A10 | The "ready agent" rename (quick-260524-3p3, commit eadb1ee) does not affect any Phase 35 source surface | Codebase mutations | Verified: rename touched `src/owl/resume.rs` lines 55,71 and 3 SKILL.md files (force-stop / list-agents / revive). None of these are Phase 35 surfaces. The new `/psyche-sync-setup/SKILL.md` should use "live agent" / "ready agent" / "psyche-wrapper" terminology consistently with CONTEXT.md vocabulary. [VERIFIED] |

## Open Questions

1. **Unix detached-spawn helper does not exist yet — confirmed.**
   - What we know: `crate::common::win_spawn::spawn_detached_no_inherit` is Windows-only at `src/common/win_spawn.rs:222`. `src/common/process.rs` contains only `killpg`-based termination helpers (lines 142 / 152).
   - Recommendation: Plan 35-03 adds `crate::common::process::spawn_detached_unix(exe, args) -> Result<u32, ()>` mirroring the Windows signature. Use `libc::fork` + `libc::setsid` + dup2 to `/dev/null` for stdio. The existing `libc` dep in `Cargo.toml` (per CLAUDE.md tech-stack) covers this. **ROADMAP plan list already names this Plan 35-03 — verified.**

2. **HTTPS vs SSH URL for `git remote add origin`?**
   - What we know: `gh auth setup-git` configures git's credential helper for HTTPS auth matching the user's gh login method. User may have independent SSH keys.
   - Recommendation: Default to HTTPS (`https://github.com/{user}/spt-agent-storage.git`) — matches the "gh is setup-only" framing best. SSH would imply the user pre-configured SSH which is outside SPT's responsibility. If users complain, add a settings override later. ROADMAP example uses the SSH form (`git@github.com:user/...`) — researcher recommends HTTPS as the safer default; planner confirms with user at plan-checker pass.

3. **Single URL vs both forms persisted in settings.json `sync.remote_url`?**
   - Recommendation: persist exactly one form (whatever was written to `git remote add origin`). `sync.remote_url` is purely informational for doctor display; runtime uses git's per-worktree `remote.origin.url` config which is the source of truth.

4. **`AskUserQuestion` framing strings (auto vs manual) — final wording?**
   - Recommendation: defer to skill author + user during plan-checker pass. Working drafts:
     - **Auto:** "Cross-machine context sync via a private GitHub repo (`spt-agent-storage`) is available. Enable now?"
     - **Manual:** "About to create the private GitHub repo `spt-agent-storage` under your account and enable sync. Proceed?"

5. **Should `accept_flow` step 1 invoke `gh repo create` with `--clone=false --source=.` to capture the resolved URL on stdout?**
   - Recommendation: yes — capture stdout; parse for the URL; use it verbatim for `git remote add origin`. Falls back cleanly: if parse fails, construct the canonical URL from `{user}/spt-agent-storage` template.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `git` CLI | All runtime sync ops + Phase 24 already requires | Inherited (D-02 missing-git soft-fail covers absence) | n/a — probed by `git --version` | D-02: silent degrade |
| `gh` CLI | Setup-only (D-16) | Probed by `gh --version` exit 0 | n/a — D-08 gate skips auto-prompt when missing | D-13: skill drives install via winget/brew/apt OR shows download URL |
| GitHub account | Setup-only (one-time auth) | Assumed when user accepts `AskUserQuestion` | n/a | None — phase is GitHub-bound (Enterprise/self-hosted deferred per CONTEXT) |
| Network connectivity | Every sync cycle | Detected by subprocess timeout/nonzero exit | n/a | D-18 backoff handles intermittent loss |

**Missing dependencies with no fallback:** None at runtime — phase degrades to "sync disabled" when gh or network absent.

**Missing dependencies with fallback:** All three CLIs have graceful degradation via Phase 24's existing soft-fail posture.

## Sources

### Primary (HIGH confidence — verified in this re-research)
- In-repo source — `src/common/tracked.rs:1210` (`commit_agent_payload` integration site for D-04 second trigger) [VERIFIED 2026-05-24]
- In-repo source — `src/common/tracked.rs:1241` (`commit_project_payload` integration site) [VERIFIED 2026-05-24]
- In-repo source — `src/common/tracked.rs:492` (`ensure_agent_worktree` — D-10 step 5 one-line conditional extension site) [VERIFIED 2026-05-24]
- In-repo source — `src/common/tracked.rs:500` (`ensure_project_worktree` — D-10 step 5 one-line conditional extension site) [VERIFIED 2026-05-24]
- In-repo source — `src/common/git.rs:472` (`run_git_checked` — extension via 30s `Duration` argument; no wrapper needed) [VERIFIED 2026-05-24]
- In-repo source — `src/common/win_spawn.rs:222` (`spawn_detached_no_inherit` — D-05 fire-and-forget pull) [VERIFIED 2026-05-24]
- In-repo source — `src/common/owlery.rs:763` (`derive_current_repo_names` — D-06 `cwd_project` resolution; line shifted from prior research's quoted 447) [VERIFIED 2026-05-24]
- In-repo source — `src/common/owlery.rs:18` (`spt_home`) and `:64` (`owlery_dir`) and `:105,111` (`agent_worktree_path` / `project_worktree_path`) [VERIFIED 2026-05-24]
- In-repo source — `src/common/process.rs` (grep returned only `killpg` lines 142 / 152 — no detached-spawn helper, confirming Wave-0 gap A3) [VERIFIED 2026-05-24]
- In-repo source — `src/owl/plugin_session_start.rs:139-155` (`emit_auto_pick` — SessionStart `additionalContext` pattern to mirror for D-08) [VERIFIED 2026-05-24]
- In-repo source — `src/owl/plugin_session_start.rs:99-132` (`should_emit_auto_pick` predicate — pure-fn pattern to mirror for `should_emit_sync_prompt`) [VERIFIED 2026-05-24]
- In-repo source — `src/owl/hook_prompt.rs:22-122` (UserPromptSubmit handler — extension site; current shape has wake-sentinel + non-wake branches both finishing with `output_hook_response`) [VERIFIED 2026-05-24]
- In-repo source — `src/owl/hook_idle.rs:62-82` (orthogonal-hook side-effects principle codification — version-change emission does NOT early-return; preserves `set_idle_ready` + `spawn_echo_commune_if_live`) [VERIFIED 2026-05-24]
- In-repo source — `src/owl/doctor.rs:22-60` (`run()` results-chain — insertion site for `check_sync_status`) [VERIFIED 2026-05-24]
- In-repo source — `src/live/start.rs:187` (`run`) and `:610` (`live_start_result`) — D-08 auto-detect insertion points [VERIFIED 2026-05-24]
- In-repo source — `src/common/auto_setup.rs:11-115` (`~/.claude/settings.json` read-modify-write pattern; NOTE: target file is different from Phase 35's `$SPT_HOME/settings.json` — Pitfall 7) [VERIFIED 2026-05-24]
- [`gh auth setup-git` manual](https://cli.github.com/manual/gh_auth_setup-git) — global vs per-host scope semantics
- [git-rebase docs](https://git-scm.com/docs/git-rebase) — `-X theirs` strategy
- [git-push docs](https://git-scm.com/docs/git-push) — `--all` ref-set behavior; missing-upstream semantics

### Secondary (MEDIUM confidence)
- [Adam Johnson — Git: Detect an in-progress rebase / cherry-pick / etc.](https://adamj.eu/tech/2023/05/29/git-detect-in-progress-operation/) — `.git/rebase-merge` vs `.git/rebase-apply` directory check
- [cli/cli #5798](https://github.com/cli/cli/issues/5798), [#6740](https://github.com/cli/cli/issues/6740), [#13032](https://github.com/cli/cli/issues/13032) — gh missing-scope error wording
- [GitHub community #52522](https://github.com/orgs/community/discussions/52522) — 404 vs 403 contract on inaccessible / nonexistent private repos
- [Red Hat Developer — Drop git pull for fetch and rebase](https://developers.redhat.com/articles/2023/09/07/drop-git-pull-fetch-and-rebase) — rationale for fetch+rebase over pull-rebase

### Tertiary (LOW confidence)
- [Atlassian — Rewriting history with rebase](https://www.atlassian.com/git/tutorials/rewriting-history/git-rebase) — general rebase behavior context

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every primitive is an in-repo helper that has shipped and been operator-validated through prior phases; all line numbers re-verified in this refresh against the post-25.4 / v1.11.14 codebase.
- Architecture: HIGH — composition over invention; the only new module (`sync.rs`) sits as a peer of existing `tracked.rs` with the same subprocess plumbing. The only fresh primitive is the Unix detached-spawn helper.
- Pitfalls: HIGH (Pitfalls 1, 2, 7, 10) — directly validated against current source. MEDIUM (Pitfalls 3-6, 8, 9) — documented patterns but unverified end-to-end against a live GitHub account.
- gh CLI behavior (Pitfall 5, A1): MEDIUM — based on issue-thread aggregation, not a direct gh source audit. Planner verifies stderr format empirically during integration test development.

**Mutation-since-prior-research deltas captured:**
- Phase 25.4 closed (perch-path resolver + Class-E) — Pitfall 10 added to prevent category errors.
- v1.11.14 deployed (6f48d61) — no Phase 35 surface impact; noted for context.
- "plain owl listener" → "ready agent" rename (eadb1ee) — A10 confirms no impact.
- psyche.md typed-envelope classifier rewrite (b947772, 75bb2d6) — A8 confirms no impact; Phase 35 does not touch psyche.md.
- Stop hook early-return fix (72b4263) + `hook_idle.rs:62-82` orthogonal-hook codification — directly anchors Pattern 2's anti-pattern guidance.
- `src/common/owlery.rs::derive_current_repo_names` line shifted from prior 447 to current 763 — citations refreshed.
- `src/common/tracked.rs::commit_agent_payload` at 1210 (not prior 1168), `commit_project_payload` at 1241 — citations refreshed.
- ROADMAP §Phase 35 plan list (9 plans) read directly — research recommendations cross-reference plan numbers where the ROADMAP already names them (e.g. Plan 35-03 for the Unix detach helper, Plan 35-06 for the post-commit hook + maybe_add_origin wiring).

**Research date:** 2026-05-24
**Valid until:** 2026-06-24 (30 days — gh CLI is stable; git CLI is stable; v1.8 milestone is closing soon so codebase churn is bounded to in-phase work)

## RESEARCH COMPLETE
