---
phase: 35.3-psyche-sync-setup-ux-pass-error-display-doctor-partial-docs
plan: 03
type: execute
wave: 2
depends_on: ["35.3-01"]
files_modified:
  - src/owl/doctor.rs
  - src/common/git.rs
autonomous: true
requirements: [SYNC-DOCTOR-PARTIAL-01]
must_haves:
  truths:
    - "(D-03, D-06, D-07) When sync state==Unset but `seed/.git/config` has an `origin` remote, `$OWL doctor` surfaces a Warn (not Fail) row indicating partial setup, pointing the operator at the idempotent re-run."
    - "(D-04) The partial-state probe is timeout-bounded: on `git ls-remote origin` timeout/failure it degrades to 'origin configured locally, remote unverified' rather than hanging or blocking doctor."
    - "The doctor collapse rule is preserved: the new row appends before the `state != Enabled` short-circuit and does not perturb doctor's overall exit semantics."
  artifacts:
    - path: "src/owl/doctor.rs"
      provides: "Partial-setup Warn row in check_sync_status under the Unset state"
      contains: "partial setup"
  key_links:
    - from: "src/owl/doctor.rs check_sync_status"
      to: "src/common/git.rs run_git_with_timeout"
      via: "bounded ls-remote origin probe against seed dir"
      pattern: "run_git_with_timeout"
---

<objective>
Resolve Issue 6 (SYNC-DOCTOR-PARTIAL-01, D-03..D-07): doctor reports
"not configured" even after a sync-setup that pushed successfully but failed at
the final settings.json write (Step 6), leaving `state==Unset`. Add a probe-only
Warn row: when `state==Unset`, probe `seed/.git/config` for an `origin` remote
and a (timeout-bounded) `git ls-remote origin`; surface a partial-setup Warn row
with the D-07 locked wording pointing at the idempotent re-run.

Probe-only — NO SyncSettings schema change (D-03/D-05; backward-compat hard
constraint for shared users). `accept_flow_attempt_ts` persistence is DEFERRED.

Purpose: An operator whose setup half-succeeded sees actionable evidence instead
of a misleading "not configured".
Output: A bounded probe + one Warn row appended in `check_sync_status`.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/35.3-psyche-sync-setup-ux-pass-error-display-doctor-partial-docs/35.3-CONTEXT.md
@.planning/phases/35.3-psyche-sync-setup-ux-pass-error-display-doctor-partial-docs/35.3-RESEARCH.md

<interfaces>
<!-- Extracted from codebase — executor needs no exploration. -->

From src/owl/doctor.rs (check_sync_status, lines 1130-1191):
- `fn check_sync_status() -> Vec<DiagResult>`
- `let settings = owlery::read_sync_settings();` then a `mut out: Vec<DiagResult>`.
- Builds `global_status` (Unset → DiagStatus::Warn) + `global_detail` (Unset → "not configured — run /psyche-sync-setup ...") and `out.push(DiagResult{...})` at lines 1170-1174.
- Recovered-aborts Warn row pushed at 1177-1186 (D-03 precedent for appending a conditional Warn row).
- SHORT-CIRCUIT at lines 1188-1191: `if settings.state != SyncState::Enabled { return out; }`. The NEW partial-state probe row must be pushed AFTER the global row (1174) and BEFORE this return — i.e. inside the Unset path, before short-circuit.
- DiagResult: `struct DiagResult { name: String, status: DiagStatus, detail: String }`; DiagStatus has `Pass | Warn | Fail` (doctor.rs:10-18).
- doctor.rs has NO `#[cfg(test)]` module currently.

From src/common/git.rs (run_git_with_timeout, lines 555-585):
- CURRENT signature: `fn run_git_with_timeout(args: &[&str], cwd: &std::path::Path) -> Option<String>` — PRIVATE. Returns trimmed stdout on success, None on timeout(500ms)/nonzero/spawn-fail. Already applies `hide_window` (no Windows console flash) and zombie-safe wait.
- This fn must be promoted to `pub(crate)` so doctor.rs can call it (`crate::common::git::run_git_with_timeout(...)`).

From src/common/owlery.rs (seed dir + sync settings):
- `read_sync_settings()` already imported in doctor.rs as `owlery::read_sync_settings`.
- Seed dir helper: the tracked seed lives at `psyches/tracked/seed/` under SPT_HOME. Use the existing seed-path accessor in owlery/tracked (look for `seed_dir`/`tracked_seed_dir`-style helper; the `.git/config` lives directly under it). RESEARCH cites the probe target as `seed/.git/config` for the origin check.

D-07 LOCKED row wording (verbatim) when origin present + ls-remote ok:
  "partial setup — origin configured but sync state=Unset (accept_flow likely failed at settings write); re-run /spt:psyche-sync-setup to converge."
D-04 degrade wording when ls-remote times out/fails but origin IS configured locally:
  "origin configured locally, remote unverified — re-run /spt:psyche-sync-setup to converge."
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Promote run_git_with_timeout to pub(crate) + add bounded ls-remote probe helper in doctor</name>
  <files>src/common/git.rs, src/owl/doctor.rs</files>
  <read_first>
    - src/common/git.rs (lines 545-585 — `run_git_with_timeout`, the helper to reuse + promote)
    - src/owl/doctor.rs (lines 1130-1191 — `check_sync_status`; lines 1-70 — `DiagResult`/`DiagStatus` defs + imports)
    - src/common/owlery.rs (grep for the tracked-seed dir accessor used elsewhere — the `.git/config` origin check needs the seed path)
  </read_first>
  <behavior>
    - A private doctor helper `probe_partial_sync(seed_dir: &Path) -> Option<DiagResult>` returns:
      - `Some(Warn row with D-07 verbatim wording)` when `seed/.git/config` contains an `[remote "origin"]` / `origin` url AND `run_git_with_timeout(&["ls-remote","origin"], seed_dir)` returns Some.
      - `Some(Warn row with D-04 degrade wording)` when origin IS configured locally but ls-remote returns None (timeout/fail) — remote unverified, still surfaced.
      - `None` when no origin is configured at all (nothing partial to report — leave the plain "not configured" global row as-is).
  </behavior>
  <action>
    In src/common/git.rs, change `fn run_git_with_timeout` (line 555) to `pub(crate) fn run_git_with_timeout` so doctor can reuse it (D-04 — reuse the timeout-wrapped helper; do NOT hand-roll a raw Command — RESEARCH Don't Hand-Roll + Pitfall 3 Windows console-flash). In src/owl/doctor.rs add a private helper `probe_partial_sync` per the behavior block. Detect the origin by reading `seed_dir.join(".git/config")` to a string and checking for an `origin` remote section (a substring match on `[remote "origin"]` is sufficient and avoids a git subprocess for the local check). Use `crate::common::git::run_git_with_timeout(&["ls-remote", "origin"], seed_dir)` for the bounded remote check (matches the existing 500ms DOCTOR posture). Use the D-07 verbatim wording for the remote-ok row and the D-04 degrade wording for the timeout/fail row. Status = DiagStatus::Warn for BOTH (D-06 — never Fail). name = "sync:partial".
  </action>
  <verify>
    <automated>cargo build --release</automated>
  </verify>
  <acceptance_criteria>
    - `cargo build --release` succeeds.
    - `run_git_with_timeout` is declared `pub(crate)` in git.rs.
    - `probe_partial_sync` exists in doctor.rs and returns `Option<DiagResult>` with `DiagStatus::Warn` on both Some arms.
    - The D-07 string is present verbatim in doctor.rs (grep `partial setup — origin configured but sync state=Unset`).
    - No raw `Command::new("git")` added in doctor.rs for this probe (reuse path only).
  </acceptance_criteria>
  <done>The bounded probe helper exists, reuses the timeout-wrapped git helper, and carries the locked Warn wording for both the verified and degraded cases.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Wire probe row into check_sync_status before the not-Enabled short-circuit + collapse-rule test</name>
  <files>src/owl/doctor.rs</files>
  <read_first>
    - src/owl/doctor.rs (lines 1130-1191 — `check_sync_status`, specifically the global push at 1170-1174, the recovered-aborts append at 1177-1186, and the `state != Enabled` short-circuit at 1188-1191)
  </read_first>
  <behavior>
    - When `settings.state == SyncState::Unset`, after pushing the global "not configured" row, call `probe_partial_sync(seed_dir)` and `out.push(...)` the returned row if `Some`. The `state != Enabled` short-circuit at 1188-1191 still returns immediately after (no per-branch rows for Unset) — collapse rule preserved.
    - For non-Unset states, behavior is byte-identical to before (probe is gated on Unset).
    - Test (doctor collapse-rule / probe gating): a unit test in a new `#[cfg(test)] mod tests` in doctor.rs that constructs an Unset settings + a temp seed dir WITHOUT an origin and asserts `probe_partial_sync` returns None (no spurious partial row); and a second case with a planted `[remote "origin"]` in `seed/.git/config` asserts a Warn row is produced (ls-remote will fail against a fake remote → exercises the D-04 degrade arm, status Warn, wording = degrade string). Keep platform-neutral; gate on git presence if ls-remote is exercised.
  </behavior>
  <action>
    In src/owl/doctor.rs `check_sync_status`, inside the Unset handling (after the global `out.push` at 1174, before the `if settings.state != SyncState::Enabled` short-circuit at 1188), add: `if settings.state == SyncState::Unset { if let Some(row) = probe_partial_sync(&seed_dir) { out.push(row); } }` — resolve `seed_dir` via the tracked-seed accessor identified in Task 1 read_first. Do NOT change `global_status`/`global_detail`, the recovered-aborts block, the short-circuit, or any per-branch logic (D-06 — don't perturb exit semantics; collapse rule intact). Add a `#[cfg(test)] mod tests` to doctor.rs per the behavior block (this module does not exist yet — create it). Use SPT_HOME tempdir + serial isolation idiom matching owlery.rs tests; gate the ls-remote-exercising case behind a git-presence check.
  </action>
  <verify>
    <automated>cargo test --lib doctor::tests</automated>
  </verify>
  <acceptance_criteria>
    - `cargo test --lib doctor::tests` passes.
    - A no-origin temp seed → `probe_partial_sync` returns None (asserted).
    - A planted-origin temp seed → a Warn row with the degrade wording is produced (asserted; ls-remote against a fake remote fails → D-04 arm).
    - `cargo test` full suite green (no regression to other doctor rows / collapse behavior).
    - Doctor still returns early for Unset (no per-branch rows) — collapse rule unchanged.
  </acceptance_criteria>
  <done>The partial-setup Warn row appears for Unset+origin states, is gated to Unset, preserves the collapse rule, and is covered by a doctor unit test.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| doctor → git subprocess → remote | `git ls-remote origin` contacts the configured remote URL during a read-only diagnostic |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-35.3-05 | Tampering | `git ls-remote origin` (untrusted remote name/URL → command injection) | mitigate | Invoke via `run_git_with_timeout(&["ls-remote","origin"], seed_dir)` — an arg-VECTOR (no shell string interpolation), so a hostile origin URL cannot inject a command. Confirmed: `run_git_with_timeout` uses `Command::new("git").args(...)`, never a shell. |
| T-35.3-06 | Denial of Service | doctor hanging on an unreachable remote | mitigate | The probe is 500ms-bounded (`run_git_with_timeout` kills + reaps the child); on timeout it degrades to the D-04 "remote unverified" Warn row rather than blocking (D-04, D-06). |
| T-35.3-07 | Information Disclosure | doctor escalating Unset to Fail / changing exit semantics | accept | Disposition Warn-only by design (D-06); doctor's overall exit semantics are unchanged. Low risk — read-only diagnostic on a local-only repo. |
| T-35.3-SC | Tampering | npm/pip/cargo installs | mitigate | No package installs; no new deps. Audit N/A per RESEARCH "Package Legitimacy Audit: Not applicable". |
</threat_model>

<verification>
- `cargo test --lib doctor::tests` passes; `cargo test` full suite green.
- `cargo build --release` succeeds.
- Manual: simulate Unset-with-origin (configure origin in a temp seed, leave settings Unset) → `$OWL doctor` shows a single `sync:partial` Warn row with the D-07 (or D-04 degrade) wording; doctor exit code unchanged vs. baseline Unset.
</verification>

<success_criteria>
- SYNC-DOCTOR-PARTIAL-01 satisfied: partial-success setup surfaces a Warn row, no schema change.
- Probe reuses `run_git_with_timeout` (bounded, no console flash, arg-vector — no injection).
- Collapse rule + Warn severity + exit semantics preserved (D-06).
</success_criteria>

<output>
Create `.planning/phases/35.3-psyche-sync-setup-ux-pass-error-display-doctor-partial-docs/35.3-03-SUMMARY.md` when done.
</output>
