# Phase 35: Psyche Sync — Pattern Map

**Mapped:** 2026-05-24
**Files analyzed:** 11 (4 new, 7 modified)
**Analogs found:** 11 / 11

Pattern map for downstream `gsd-planner`. Every Phase 35 surface has a verified
in-repo analog with concrete line numbers refreshed against the post-25.4 /
v1.11.14 tree. Planner copies-and-adapts; nothing here invents architecture.

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `src/common/sync.rs` (NEW) | new module — sync primitives | request-response over git subprocess | `src/common/tracked.rs` (Phase 24 peer module — same subprocess plumbing, same soft-fail posture) | exact role+flow |
| `src/common/process.rs` (MODIFY — add `spawn_detached_unix`) | utility — detached process spawn | fire-and-forget event | `src/common/win_spawn.rs::spawn_detached_no_inherit` (line 222) | exact role+flow, Unix mirror needed |
| `src/common/owlery.rs` (MODIFY — add `read_sync_settings` / `write_sync_settings` / `sync_settings_path`) | settings persistence | read-modify-write | `src/common/auto_setup.rs` lines 11-115 (read-modify-write `~/.claude/settings.json`) + `src/owl/plugin_session_start.rs::sync_settings_json` | role-match (DIFFERENT target file — see Pitfall 7 in RESEARCH.md) |
| `src/common/tracked.rs` (MODIFY — `commit_agent_payload` line 1210, `commit_project_payload` line 1241 tail-end sync hook + `ensure_agent_worktree` line 492, `ensure_project_worktree` line 500 one-line remote-add extension) | integration site | request-response | self (Phase 24 lifecycle — extend tail of existing functions) | exact (extension, not replacement) |
| `src/owl/hook_prompt.rs` (MODIFY — append async-pull dispatch AFTER existing branches) | hook handler | fire-and-forget event | `src/owl/hook_idle.rs` lines 62-82 (orthogonal-hook side-effects principle — `version_changelog::maybe_emit_version_change_block` deliberately NOT early-returned) | exact (same orthogonal-hook posture) |
| `src/owl/plugin_session_start.rs` (MODIFY — add `emit_sync_prompt` sibling branch + `should_emit_sync_prompt` predicate) | hook handler | event-driven | self — `emit_auto_pick` lines 139-155 + `should_emit_auto_pick` predicate lines 99-132 (Phase 33 AUTO-03) | exact (same hookSpecificOutput envelope) |
| `src/owl/doctor.rs` (MODIFY — add `check_sync_status` returning N rows, push into `results` chain at line 22) | utility — diagnostic surface | request-response | `check_tracked_layout` at lines 699-790 (Phase 24 D-17 doctor surface — bare row + per-worktree rows with PASS/WARN/FAIL classification) | exact role+flow |
| `src/live/start.rs` (MODIFY — `run()` line 187, `live_start_result()` line 610: D-08 auto-detect gate; queues SessionStart emission) | lifecycle wiring | event-driven | self (existing collision-check + perch resolution preceding the wrapper-spawn block) | role-match (insertion point, no direct analog) |
| `src/cli.rs` (MODIFY — add `SyncPullAsync` + `PsycheSyncSetup` variants) | config — clap subcommand registration | request-response | self — existing `SessionResume` (line 140) / `PluginSessionStart` (line 144) shape | exact |
| `src/owl/mod.rs` (MODIFY — register new subcommand dispatch) | config — module wiring | request-response | self — existing dispatcher entries | exact |
| `plugin/spt/skills/psyche-sync-setup/SKILL.md` (NEW) | skill manifest | event-driven | `plugin/spt/skills/force-stop/SKILL.md` (multi-branch decision skill with `$OWL`/`$LIVE` invocation + AskUserQuestion-friendly framing) | role-match |

## Pattern Assignments

### `src/common/sync.rs` (new module — sync primitives)

**Analog:** `src/common/tracked.rs` (Phase 24 peer module).

**Module header pattern** (`src/common/tracked.rs` lines 1-47):
```rust
//! Phase 24 — `tracked/` forked-repo lifecycle.
//!
//! Owns the bare-seed + linked-worktree layout under
//! `$SPT_HOME/psyches/tracked/`:
//! ...
//! Locked decisions implemented here:
//!   D-01  shell out to system `git` CLI — no `git2-rs`, no bundled git.
//!   D-02  missing-git fallback: degrade silently to non-versioned writes.
//!   ...
//!
//! Every git subprocess routes through `crate::common::git::run_git_checked`
//! so the Phase 23 soft-fail + zombie-safe + hide-window posture extends
//! here verbatim.
```
Phase 35 module header MUST enumerate D-01..D-19 the same way and explicitly
state "Every git subprocess routes through `run_git_checked` with
`Duration::from_secs(30)` (D-07) — no new wrapper".

**Imports pattern** (`src/common/tracked.rs` lines 41-47):
```rust
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use crate::common::git::{self, GitError};
use crate::common::owlery;
use crate::common::time::now_iso_utc;
```
Phase 35 `sync.rs` imports the same crate::common surfaces + adds nothing new
(no new crate dep — see RESEARCH §Standard Stack).

**Core subprocess pattern** (called from `tracked.rs` repeatedly):
```rust
git::run_git_checked(
    &["-C", &worktree.to_string_lossy(), "fetch", "origin", branch],
    None, Duration::from_secs(30))?;        // D-07 — 30s budget for sync ops
```
The existing `run_git_checked` already takes a `Duration` parameter (verified
at `src/common/git.rs:472-476`). No wrapper. No new helper.

**Soft-fail posture** (Phase 23/24 D-02): every sync subprocess returns
`Result<_, SyncError>`; caller chains `let _ = ...` to swallow — see
RESEARCH Pattern 4 (post-commit hook placement).

**Pure-predicate gate** (mirror Phase 35 RESEARCH Pattern 6, new pattern but
predicate-purity matches `should_emit_auto_pick` at `plugin_session_start.rs:99-132`):
```rust
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,
    }
}
```

---

### `src/common/process.rs` (add `spawn_detached_unix`)

**Analog:** `src/common/win_spawn.rs::spawn_detached_no_inherit` (line 222).

**Signature to mirror** (`src/common/win_spawn.rs` lines 215-274):
```rust
/// Spawn `exe` with `args` fully detached: no stdio, no console, breakaway
/// from the parent's job, `bInheritHandles = FALSE`. Best-effort -- on
/// failure returns Err(()) and the caller should treat it as fire-and-forget.
pub fn spawn_detached_no_inherit(
    exe: &Path,
    args: &[&str],
    envs: &[(String, String)],
) -> Result<u32, ()> {
    let mut cmdline = build_command_line(exe, args);
    ...
    let mut flags = DETACHED_PROCESS
        | CREATE_NEW_PROCESS_GROUP
        | CREATE_BREAKAWAY_FROM_JOB
        | CREATE_NO_WINDOW;
    ...
    let ok = unsafe {
        CreateProcessW(
            std::ptr::null(),
            cmdline.as_mut_ptr(),
            std::ptr::null_mut(),
            std::ptr::null_mut(),
            0, // bInheritHandles = FALSE
            flags,
            env_ptr,
            std::ptr::null(),
            &mut si,
            &mut pi,
        )
    };
    ...
    Ok(pid)
}
```

**Unix mirror sketch** for `spawn_detached_unix(exe, args) -> Result<u32, ()>`:
- `libc::fork` → child path: `libc::setsid` (new session, detach from controlling TTY) → `dup2` stdin/stdout/stderr to `/dev/null` → `execve(exe, argv, envp)`.
- Parent path: `waitpid(child, NULL, WNOHANG)` to reap immediately if grandchild double-fork is used, else return child pid.
- Existing libc usage precedent in `src/common/process.rs` lines 142, 152 (`libc::killpg`, `libc::kill`).

**Critical:** Signature MUST match Windows helper exactly so Phase 35 callers can `#[cfg(unix)]` / `#[cfg(windows)]` split cleanly (see RESEARCH Pattern 2).

---

### `src/common/owlery.rs` (add `read_sync_settings` / `write_sync_settings` / `sync_settings_path`)

**Analog:** `src/common/auto_setup.rs` lines 11-115 (read-modify-write
`~/.claude/settings.json`).

**Path resolution pattern** (analog: `auto_setup.rs:13-17`):
```rust
let home = std::env::var("HOME")
    .or_else(|_| std::env::var("USERPROFILE"))
    .expect("HOME or USERPROFILE must be set");
let settings_path = PathBuf::from(&home).join(".claude").join("settings.json");
```
Phase 35 `sync_settings_path()` MUST target `spt_home().join("settings.json")`,
NOT `~/.claude/settings.json` — Pitfall 7 in RESEARCH.md is load-bearing.
Reference `spt_home()` defined at `src/common/owlery.rs:18`.

**Read-then-mutate pattern** (analog: `auto_setup.rs:30-58` for fresh-env case
+ `:61-119` for existing-file case):
```rust
let content = match fs::read_to_string(&settings_path) {
    Ok(c) => c,
    Err(_) => return,   // sync: return default SyncSettings
};
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap_or_default();
// extract "sync" object → SyncSettings; default on absence
```

**Atomic-write pattern** (analog: `auto_setup.rs:51-54`):
```rust
let formatted = serde_json::to_string_pretty(&settings)
    .expect("Failed to serialize settings.json");
fs::write(&settings_path, formatted)
    .expect("Failed to write settings.json");
```
Phase 35 write helper MUST splice/replace ONLY the `"sync"` key (preserve
adjacent keys other tools might add). Use `serde_json::Value::as_object_mut`
+ `insert("sync", ..)`.

**Helper naming caveat** (RESEARCH Pitfall 7): name them
`read_sync_settings` / `write_sync_settings` / `sync_settings_path` — NOT
`read_settings` / `write_settings` — to avoid collision with the existing
claude-code-settings helpers in `auto_setup.rs` and
`plugin_session_start.rs::sync_settings_json`.

---

### `src/common/tracked.rs::commit_agent_payload` / `commit_project_payload` (post-commit sync hook insertion)

**Analog:** the function bodies themselves at lines 1210-1254.

**Current shape** (`src/common/tracked.rs` lines 1210-1254):
```rust
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),
    )
}

pub fn commit_project_payload(
    project_name: &str,
    files: &[&str],
    subject: &str,
) -> Result<(), TrackedError> {
    let wt = ensure_project_worktree(project_name)?;
    commit_payload(
        &wt,
        files,
        subject,
        crate::common::git::TrailerScope::Project,
        Duration::from_millis(PAYLOAD_TIMEOUT_MS),
    )
}
```

**Phase 35 D-04 extension** (insert AFTER the existing inner call, BEFORE
returning):
```rust
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 pull-then-push (soft-fail per D-14).
    let branch = crate::common::owlery::agent_branch(agent_id);
    let worktree = crate::common::owlery::agent_worktree_path(agent_id);
    let _ = crate::common::sync::sync_after_commit(&branch, &worktree);
    Ok(())
}
```
Soft-fail pattern (`let _ = ...`) mirrors the existing
`commit_agent_payload_with_timeout` semantics — sync failure NEVER blocks
delivery; payload is already on disk + in the local commit.

**Source-order test** (D-04 second-trigger): RESEARCH §Validation calls for an
`include_str!` byte-scan test pinning the sync call AFTER the commit. Phase
28/29 precedent — see RESEARCH line 633.

---

### `src/common/tracked.rs::ensure_agent_worktree` / `ensure_project_worktree` (one-line remote-add extension — D-10 step 5)

**Analog:** the function bodies themselves at lines 492-502.

**Current shape**:
```rust
/// D-04 / D-16 / Pitfall 1. Per the D-13 amendment, NO `git remote add
/// origin` step runs — Phase 24 is deliberately no-push.
pub fn ensure_agent_worktree(agent_id: &str) -> Result<PathBuf, TrackedError> {
    ensure_worktree(EnsureScope::Agent, agent_id)
}

pub fn ensure_project_worktree(project_name: &str) -> Result<PathBuf, TrackedError> {
    ensure_worktree(EnsureScope::Project, project_name)
}
```

**Phase 35 extension** — D-10 step 5 ("future Phase 24 D-16 lazy-created
worktrees inherit the remote automatically"):
```rust
pub fn ensure_agent_worktree(agent_id: &str) -> Result<PathBuf, TrackedError> {
    let wt = ensure_worktree(EnsureScope::Agent, agent_id)?;
    // Phase 35 D-10 step 5 — if sync enabled, ensure origin remote is wired.
    crate::common::sync::maybe_add_origin(&wt);
    Ok(wt)
}
```
The CONTEXT.md doc comment about "Phase 24 is deliberately no-push" MUST be
updated to reflect the D-10 amendment.

`maybe_add_origin` is a new helper in `sync.rs` that:
1. Reads `sync.state`; returns immediately if not `Enabled`.
2. Checks `git -C {wt} remote` — returns if `origin` already present.
3. Otherwise: `git -C {wt} remote add origin {sync.remote_url}` with
   `Duration::from_secs(5)` timeout.

---

### `src/owl/hook_prompt.rs` (UserPromptSubmit async-pull dispatch — D-04 first trigger)

**Analog:** `src/owl/hook_idle.rs` lines 62-82 (orthogonal-hook side-effects
codification).

**Critical-pattern excerpt** (`src/owl/hook_idle.rs:62-82`):
```rust
let skip_version_change = std::env::var("OWL_ECHO_COMMUNE")
    .map(|v| !v.is_empty())
    .unwrap_or(false)
    || stdin.stop_hook_active;

if !skip_version_change {
    // Phase 25.4-07 follow-up: version-change emission must NOT short-circuit
    // the rest of the Stop hook. Both downstream calls (set_idle_ready and
    // spawn_echo_commune_if_live) are pure filesystem sentinel writes with
    // no stdout output — orthogonal to the deferred spool delivery this
    // emission produces.
    //
    // Return value retained as `let _` to preserve REVIEW-FIX #2 compile-time
    // VersionPrompt type pin.
    let _ = version_changelog::maybe_emit_version_change_block(&owl_id);
}

// 3. Set .idle-ready so poll can deliver (per 18.1 D-02, D-04).
inbox::set_idle_ready(&owl_id);
```

**Phase 35 mirror** — appended AFTER both existing branches in
`hook_prompt.rs::run` (current shape: wake-sentinel branch ends with
`output_hook_response` at line 70 or 73; non-wake branch ends at line 120):

```rust
// Phase 35 D-04 — fire-and-forget async pull dispatch.
// MUST run AFTER existing branches' output_hook_response calls.
// Side effect ONLY (detached child spawn); no stdout, no stderr,
// no early-return — orthogonal-hook posture from hook_idle.rs:62-82.
dispatch_async_sync_pull(&owl_id);
```

**Dispatch helper** (new private fn in `hook_prompt.rs`):
```rust
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); }
}
```

**Smoke test requirement** (RESEARCH line 639): unit/source-order test
confirming the dispatcher does NOT short-circuit either of the existing
branches.

---

### `src/owl/plugin_session_start.rs` (D-08 auto-detect emission)

**Analog:** `emit_auto_pick` (lines 139-155) + `should_emit_auto_pick` predicate
(lines 99-132).

**Predicate pattern** (lines 99-132):
```rust
pub(crate) fn should_emit_auto_pick(
    env: &AutoPickEnvSnapshot,
    src: &AutoPickSourceInput,
    parent_pid_has_active_perch: bool,
) -> bool {
    match src.source.as_deref() {
        Some("startup") => {}
        _ => return false,
    }
    if src.agent_type.is_some() { return false; }
    if env.handoff_child || env.trampoline_guard { return false; }
    if env.echo_commune { return false; }
    if env.skip_resume || env.team_name { return false; }
    if parent_pid_has_active_perch { return false; }
    true
}
```
Pure predicate over a snapshot of env + parsed stdin. Phase 35 mirrors as
`should_emit_sync_prompt(settings: &SyncSettings, now_iso: &str, gh_present: bool) -> bool`
with the D-08 gates:
- `gh_present == true` (gh CLI available)
- `settings.state == Unset` OR (`settings.state == RemindLater` AND
  `now_iso >= settings.remind_after_ts`)

**Emission pattern** (lines 139-155):
```rust
pub(crate) fn emit_auto_pick(pick_spec_json: &str) {
    let context = format!(
        "<spt-live-auto-pick>\n{}\n</spt-live-auto-pick>",
        pick_spec_json
    );
    let response = serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": context,
        }
    });
    let out = serde_json::to_string(&response).unwrap_or_else(|_| {
        r#"{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":""}}"#
            .to_string()
    });
    println!("{}", out);
}
```

**Phase 35 mirror** — new sibling branch slotted in around line 205 in `run()`,
AFTER the existing `emit_auto_pick` block:
```rust
pub(crate) fn emit_sync_prompt() {
    let context = "<spt-psyche-sync-prompt>\n\
        Cross-machine context sync via private GitHub repo `spt-agent-storage`\n\
        is available. Run /psyche-sync-setup to enable.\n\
        </spt-psyche-sync-prompt>";
    let response = serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": context,
        }
    });
    println!("{}", serde_json::to_string(&response).unwrap_or_default());
}
```

**Call site** — alongside existing block at lines 205-209:
```rust
let now = crate::common::time::now_iso_utc();
let settings = crate::common::owlery::read_sync_settings();
let gh_present = crate::common::sync::gh_present();   // probes `gh --version`
if should_emit_sync_prompt(&settings, &now, gh_present) {
    emit_sync_prompt();
    // Bump last_prompted_ts to dedup within boot
}
```

---

### `src/owl/doctor.rs` (D-15 sync table addition)

**Analog:** `check_tracked_layout` at lines 699-790.

**Insertion-site pattern** (`src/owl/doctor.rs::run` lines 22-38):
```rust
pub fn run(fix: bool) {
    output::owl_status("doctor", "running diagnostics...");
    let mut results: Vec<DiagResult> = Vec::new();

    results.push(check_spt_layout());
    results.push(check_env_vars());
    results.extend(check_stale_perches(fix));
    results.push(check_registry_health(fix));
    results.extend(check_pid_files(fix));
    results.extend(check_spool_capacity());
    results.extend(check_tracked_layout(fix));
    // Phase 25 Plan 05: D-05 OFFLINE Self/Psyche surface, ...
    results.extend(check_offline_self_and_psyche());
    results.extend(check_duplicate_flat_nested(fix));
    results.extend(check_orphan_psyches());
    results.push(check_orphan_worker_count());
    ...
}
```
Phase 35 adds `results.extend(check_sync_status());` after
`check_tracked_layout` — sync surface is logically downstream of tracked-layout
health.

**Body shape** — mirror `check_tracked_layout` (lines 699-790):
```rust
fn check_sync_status() -> Vec<DiagResult> {
    let settings = owlery::read_sync_settings();
    let mut results: Vec<DiagResult> = Vec::new();

    // Global row at top (D-15)
    results.push(DiagResult {
        name: "sync".to_string(),
        status: match settings.state {
            SyncState::Enabled => DiagStatus::Pass,
            SyncState::Unset => DiagStatus::Warn,    // not configured
            SyncState::Declined | SyncState::RemindLater => DiagStatus::Pass,
            SyncState::Failing => DiagStatus::Fail,
        },
        detail: format!("state={:?} remote={}", settings.state,
            settings.remote_url.as_deref().unwrap_or("-")),
    });

    // Short-circuit if not enabled (no per-branch rows to show)
    if settings.state != SyncState::Enabled { return results; }

    // Per-branch rows: iterate worktrees via tracked::doctor_status_rows()
    // and compose per-branch sync status (last_ok / last_err / retry_after
    // from settings + per-worktree probe).
    ...
    results
}
```
Status classification mirror (line 742-756): use `DiagStatus::Pass / Warn / Fail`
+ the `name: format!("sync:{}:{}", scope_tag, branch_name)` shape consistent
with `tracked:agent:doyle` naming.

---

### `src/live/start.rs::run` / `live_start_result` (D-08 auto-detect gate)

**Analog:** the existing collision-check + perch resolution preceding the
wrapper-spawn block (lines 187-220 in `run()`; lines 610-640 in
`live_start_result()`).

**Current shape** (lines 197-220, `run()`):
```rust
let psyche_id = format!("{}-psyche", id);
let psyche_perch = owlery::nested_perch_dir(id, &psyche_id);
let wrapper_pid_file = psyche_wrapper_pid_file(id);

// Collision check: active wrapper process
if wrapper_pid_file.exists() {
    if let Ok(content) = fs::read_to_string(&wrapper_pid_file) {
        if let Ok(pid) = content.trim().parse::<u32>() {
            if types::is_process_alive(pid) {
                output::owl_err(&format!(
                    "COLLISION:{} already has an active process ...",
                    ...
                ));
                std::process::exit(1);
            }
        }
    }
}
```

**Phase 35 D-08 insertion** — added BEFORE the wrapper-spawn block (after
collision checks; sync auto-detect doesn't block boot if it fires):
```rust
// Phase 35 D-08 — auto-detect sync setup gate. Reads settings.json + probes
// `gh --version`; if state is Unset or remind-later-past-cooldown AND gh is
// present, set a queue sentinel that SessionStart will read on next boot.
// NEVER blocks — pure read + best-effort sentinel write.
crate::common::sync::queue_sync_prompt_if_due();
```

The actual `AskUserQuestion` emission rides on the SessionStart hook (see
`plugin_session_start.rs` analog above). `queue_sync_prompt_if_due()` is a
pure-fn that decides yes/no and writes a sentinel file (e.g.
`$SPT_HOME/.sync-prompt-due`) read by the next SessionStart.

Both `run()` (line 187) and `live_start_result()` (line 610) get the same
one-line insertion at equivalent positions (after collision checks, before
wrapper spawn).

---

### `src/cli.rs` (new clap subcommands)

**Analog:** `SessionResume` (line 140) + `PluginSessionStart` (line 144).

**Existing shape** (`src/cli.rs:139-144`):
```rust
/// Resume a session
SessionResume,

/// Plugin SessionStart hook handler (env var injection + session-resume)
#[command(name = "plugin-session-start")]
PluginSessionStart,
```

**Phase 35 additions**:
```rust
/// Phase 35: fire-and-forget pull for UserPromptSubmit detached child.
#[command(name = "sync-pull-async")]
SyncPullAsync {
    #[arg(long)] agent: String,
    #[arg(long)] project: Option<String>,
},

/// Phase 35: shared accept-flow driver for both auto and manual setup paths.
#[command(name = "psyche-sync-setup")]
PsycheSyncSetup {
    #[arg(long)] disable: bool,
},
```

Dispatch registration in `src/owl/mod.rs` mirrors the `SessionResume` /
`PluginSessionStart` pattern.

---

### `plugin/spt/skills/psyche-sync-setup/SKILL.md` (D-13 unified entry)

**Analog:** `plugin/spt/skills/force-stop/SKILL.md`.

**Frontmatter pattern** (`plugin/spt/skills/force-stop/SKILL.md` lines 1-12):
```markdown
---
name: force-stop
description: |
  Force-stop an SPT agent -- ready agent or live agent (with its
  Psyche). Use when the user says "stop listening", "stop owl", ...
  Session-aware: a live session (or a target with `live:true` in info.json)
  routes through `$LIVE stop` (3-step kill including Psyche teardown); ...
argument-hint: "[<id>] | --all"
allowed-tools: [Bash]
---
```

**Body shape (intro + decision branching + invocation + caveat)** — mirror
`force-stop/SKILL.md`:
- Heading: `# /spt:psyche-sync-setup`.
- Intro note that `$OWL` / `$LIVE` are env vars set by SessionStart hook.
- Section per branch: prereq check (gh present? authed?) → AskUserQuestion if
  missing → accept-flow → idempotent status display when already enabled
  (D-13 step 5).
- Single shell-invocation per branch (e.g. `$OWL psyche-sync-setup`).
- Caveat block at bottom (D-17 404 → re-run skill; D-19 disable path).

Vocabulary consistency: use "live agent" / "ready agent" / "psyche-wrapper"
per the Phase 35 CONTEXT.md (Assumption A10 in RESEARCH.md). Avoid stale
terms like "listener".

## Shared Patterns

### Soft-fail subprocess posture
**Source:** Phase 23 D-13 / Phase 24 D-02 — codified in
`src/common/git.rs::run_git_checked` (line 472) returning
`Result<String, GitError>`.

**Apply to:** every git/gh subprocess in `src/common/sync.rs` and every call
site in `tracked.rs` / `hook_prompt.rs` / `plugin_session_start.rs`.

**Excerpt** (`src/common/git.rs` lines 528-534):
```rust
if !out.status.success() {
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    return Err(GitError::Nonzero { stderr });
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
Ok(stdout)
```

Callers swallow with `let _ = ...` (post-commit hook) OR pattern-match on
`GitError::Nonzero { stderr }` to trigger the D-17 404 hard-stop classifier
(see `src/common/sync.rs::classify_and_record_outcome` in RESEARCH §Code
Examples).

### SPT_TRACE-gated stderr (Phase 18.7.1 D-04)
**Source:** existing `SPT_TRACE` env var checks throughout `src/owl/`.

**Apply to:** every sync-failure surface in `src/common/sync.rs`.

**Pattern:**
```rust
if std::env::var("SPT_TRACE").map(|v| !v.is_empty()).unwrap_or(false) {
    eprintln!("SYNC_FAIL:{} {}", branch, reason);
}
```
D-14 mandates hot-path silent; doctor + SPT_TRACE are the ONLY surfaces.

### Orthogonal-hook side-effects (no early-return)
**Source:** `src/owl/hook_idle.rs` lines 62-82 codified post-25.4.

**Apply to:** `src/owl/hook_prompt.rs` async-pull dispatch + future hook
extensions.

The principle: if your hook emission produces side effects (filesystem
writes, detached child spawn, stdout response), DO NOT use `return` to
short-circuit subsequent unrelated side effects in the same hook. Each side
effect is independently gated by its own predicate; early-return is the
classic regression source (REVIEW-FIX #2 / hotfix-260517-6om aftermath).

### Path resolution via `crate::common::owlery`
**Source:** `src/common/owlery.rs` lines 18 (`spt_home`), 64 (`owlery_dir`),
105/111 (`agent_worktree_path` / `project_worktree_path`).

**Apply to:** every Phase 35 surface that needs a directory path. NEVER call
`spt_home().join(...)` directly outside `owlery.rs`; always go through a named
helper. Phase 35 adds `sync_settings_path()` to this list.

### `derive_current_repo_names` for cwd_project (Phase 25 D-07)
**Source:** `src/common/owlery.rs` lines 763-787.

**Apply to:** `src/owl/hook_prompt.rs::dispatch_async_sync_pull` and any sync
caller needing the active project scope (D-06).

The fn returns `Vec<String>`; Phase 35 picks `.first().cloned()` per the
"active project = first match" convention.

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| — | — | — | All 11 surfaces have verified analogs. Phase 35 is composition; nothing is invented from scratch. |

The closest-to-no-analog case is `src/common/process.rs::spawn_detached_unix`,
which inverts the platform of an existing helper (`win_spawn.rs:222`) but does
not have an existing Unix counterpart. Plan 35-03 (per ROADMAP) is dedicated to
this build — see RESEARCH Open Question §1.

## Metadata

**Analog search scope:** `src/common/`, `src/owl/`, `src/live/`,
`plugin/spt/skills/`, `src/cli.rs`.
**Files scanned:** ~12 (tracked.rs, sync, owlery.rs, auto_setup.rs, git.rs,
win_spawn.rs, process.rs, hook_prompt.rs, hook_idle.rs, plugin_session_start.rs,
doctor.rs, start.rs, cli.rs, plus 1 skill manifest).
**Pattern extraction date:** 2026-05-24
**Verified-against:** post-25.4 / v1.11.14 tree (commit 6f48d61 era; all line
numbers re-checked against current src/).

## PATTERN MAPPING COMPLETE
