---
task_id: 260510-vng
slug: deploy-ps1-hardening-atomic-prune-and-po
type: quick
commit_prefix: fix(deploy)
---

# PLAN — Harden docs/DEPLOY.ps1 (atomic prune + explicit installed_plugins.json patch + post-deploy assertion)

This plan closes the regression documented in `.planning/debug/26-06-live-skill-regression.md`. Three bugs in `docs/DEPLOY.ps1` combined to produce a partially-pruned 1.9.7 cache dir whose `skills/` survived deletion long enough for Claude Code's skill resolver to load stale SKILL.md content, while `installed_plugins.json` simultaneously failed to advance off 1.9.8 because `claude plugin install spt@cplugs` is a no-op when already installed.

All edits are scoped to `docs/DEPLOY.ps1` (with corresponding prose updates in `docs/DEPLOY.md`). No Rust changes, no plugin-side changes, no process kills.

<files_to_read>

Mandatory before editing:

1. `docs/DEPLOY.ps1` — entire file. The line-number references in this plan (494-535, 583-598, etc.) match the current HEAD revision; if the file has changed, re-locate by anchor strings (`# (3-5) Prune old versions.`, `Write-Step "Step 9: Refresh plugin state via Claude Code CLI"`, etc.) rather than line numbers.
2. `docs/DEPLOY.md` — "What it does (in order)" numbered list, the step-5 prose about targeted prune, and the step-7 prose about `claude plugin install spt@cplugs`. These are the user-facing contract; they must stay in sync with script behavior.
3. `.planning/debug/26-06-live-skill-regression.md` — root cause section + "Permanent (preventing recurrence)" subsection. Each of the three fixes in this plan corresponds 1:1 to a bullet there.
4. `CLAUDE.md` — "Binary handoff (Phase 18.4/18.5)" paragraph. The handoff contract is the hard invariant the new prune logic must preserve: running `owl.exe` processes self-migrate; the deploy script never kills them and never force-deletes locked binaries.
5. `plugin/spt/.claude-plugin/plugin.json` — confirm current version (1.9.9 at plan-authoring time). Used only as a sanity check; the script reads this file as its own authoritative version source.

No other files need to be read. The change is local to the deploy tooling.

</files_to_read>

<scope>

**Delivers (in `docs/DEPLOY.ps1`):**

- A new atomic-rename prune strategy that replaces the current `Remove-Item -Recurse -Force` against prune candidates (current lines ~494-535). The rename step is what makes the operation atomic against sharing violations: `Rename-Item` on Windows either succeeds wholly or fails wholly when the source dir contains a locked file. Half-pruned dirs become impossible.
- An explicit `installed_plugins.json` patch step that reads `$env:USERPROFILE\.claude\plugins\installed_plugins.json`, rewrites the `spt@cplugs` entry's `version` / `installPath` / `lastUpdated` / `gitCommitSha`, and writes it back atomically via temp-file + `Move-Item -Force`. This replaces the current reliance on `claude plugin install spt@cplugs` as the version-pointer source-of-truth (current lines ~583-598). The `claude plugin install` call is kept for cache-warm-up side effects but is no longer load-bearing.
- A post-deploy assertion that re-reads `installed_plugins.json` and verifies `$installed.plugins.'spt@cplugs'[0].version -eq $Version`. On mismatch, write to stderr and `exit 1`. Fails loud instead of silently shipping a stale pointer.
- Companion prose updates in `docs/DEPLOY.md` so the user-facing "What it does" list reflects the new step semantics.

**Does NOT deliver:**

- No changes to `src/`, `plugin/spt/`, or `psyche.md`. No Rust rebuild logic change. No skill-content changes.
- No process-killing logic. The debug doc proposes `Stop-Process` against owl.exe whose path resolves into prune candidates; this plan explicitly rejects that approach because it violates Phase 18.4/18.5 handoff (a killed owl.exe abandons its session-state mid-migration). The atomic-rename approach achieves the same correctness goal (no stale SKILL.md visible to CC's resolver) without touching processes.
- No portable / cross-platform equivalent. DEPLOY.ps1 is Windows-only. Unix deploy continues to follow the manual steps in `docs/DEPLOY.md` "Update Marketplace Plugin Files".
- No change to the `-Bump` / `-SkipPush` / `-SkipMarketplaceCommit` / `-DryRun` parameter surface. All invocation forms keep working unchanged.
- No change to step-numbering in the `Write-Step` log labels. The DEPLOY.md note about "log-grep tooling keeps matching prior runs" is honored; new behavior is folded into existing Step 6-7 and Step 9 labels (and a new Step 10 assertion label preceding the existing "Final message" block, which renumbers as Step 11 only in the comment header — Write-Step text adjusted accordingly without breaking historical greps for "Step 9").

**Backward compatibility:**

- The canonical invocation `powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch` produces the same observable outcome (built, synced, marketplace pushed, cache populated, installed_plugins.json reflects new version, /reload-plugins reminder printed) plus the three new guarantees.
- `-DryRun` honors all new mutations (rename, JSON patch write). Each gets a `[DRY-RUN]` line.
- First-deploy case (no prior `spt@cplugs` entry) still skips prune entirely and now also skips the JSON patch (writes a fresh entry instead) and skips the assertion's "version advanced" check (asserts the entry simply exists at $Version).
- Sharing-violation residue from the OLD prune semantics (existing `.pending-prune-*` dirs from prior deploys, or true leftover half-pruned semver dirs) is best-effort cleaned at the start of the new prune step — see Task 1 details.

</scope>

<tasks>

### Task 1 — Atomic-rename prune (replace lines ~494-535)

**Goal:** Make prune of stale cache dirs atomic against sharing violations. Either the entire directory disappears from the resolver's scan namespace, or the directory is untouched. Never partial.

**Steps:**

1. In `docs/DEPLOY.ps1`, locate the block starting `# (3-5) Prune old versions.` and ending at the closing brace of the `else` branch that contains the current `foreach ($child in $pruneCandidates)` loop. Replace ONLY the inner foreach loop body (lines ~522-533). The keep-set construction (lines ~500-516) and the prune-candidate enumeration (lines ~510-516) remain unchanged — they're correct.
2. Before the foreach, add a small "carry-over cleanup" pass that picks up any `.pending-prune-*` directories left behind by previous deploys: enumerate `Get-ChildItem -Path $CacheRoot -Directory -Filter '.pending-prune-*' -ErrorAction SilentlyContinue`, attempt `Remove-Item -Recurse -Force -LiteralPath $_.FullName -ErrorAction SilentlyContinue` for each, and log per-dir success / failure via `Write-SubStep` / `Write-Warn`. This pass tolerates failure (a still-locked previous-deploy carry-over just persists another cycle) and never aborts the deploy.
3. Replace the foreach body with the new atomic-rename logic. For each prune candidate `$child`:
   - Build a unique target name: `$ts = [int][double]::Parse((Get-Date -UFormat %s))` → `$renamedName = ".pending-prune-$($child.Name)-$ts"`. The unix timestamp avoids collisions when the deploy runs twice in the same second (rare but possible in test loops); the new name's leading dot + suffix ensure it does NOT match the `^\d+\.\d+\.\d+(-\w+)?$` semver regex that defines prune candidates, so CC's resolver — which we assume scans for semver-shaped dirs — will not look inside it.
   - Under `-DryRun`, log `[DRY-RUN] rename $($child.FullName) -> $renamedName` and continue.
   - Otherwise, attempt `Rename-Item -LiteralPath $child.FullName -NewName $renamedName -ErrorAction Stop` inside a try/catch.
   - On rename success: log `Renamed $($child.Name) -> $renamedName`, then attempt `Remove-Item -Recurse -Force -LiteralPath (Join-Path $CacheRoot $renamedName) -ErrorAction SilentlyContinue`. Re-check `Test-Path` on the renamed path; if it still exists, `Write-Warn` that it's deferred (owl.exe inside is still in use, will be cleaned up by the carry-over pass on a future deploy). Crucially: the renamed dir is no longer semver-shaped, so CC's resolver cannot land on it even if it persists.
   - On rename failure (catch block): `Write-Warn` that the directory was left untouched (`"Could not rename $($child.Name) (sharing violation; owl.exe inside still executing — deferred to next deploy with no resolver-visible changes)"`) and continue to the next candidate. Do NOT fall back to a direct `Remove-Item`; that's exactly the failure mode we're eliminating.
4. Preserve all existing log lines that downstream tooling / users may grep for: `Prune candidates: ...`, `No prune candidates ...`, `First deploy detected ...`, `Cache root does not exist yet ...`. Their conditions don't change.
5. Add a one-line comment above the new block explaining the invariant: `# Atomic prune: rename-first ensures CC's skill-resolver scan never sees a half-pruned semver dir.`

**Verification:**

- `git diff docs/DEPLOY.ps1` shows the foreach body replaced with rename-first logic and a new carry-over `.pending-prune-*` cleanup pass.
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'Remove-Item -Recurse -Force -LiteralPath \$child\.FullName' -SimpleMatch` returns zero hits (the unconditional recursive delete against the original semver path is gone).
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'Rename-Item.*\.pending-prune-'` returns at least one hit.
- Dry-run smoke test: `powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -DryRun` completes successfully and the prune section emits `[DRY-RUN] rename` lines (not `[DRY-RUN] would remove`). No actual filesystem mutations.
- Manual handoff check (documented, not automated): the script must NEVER produce a line containing both the literal `Stop-Process` and `owl.exe` — confirmed by `Select-String -Path docs/DEPLOY.ps1 -Pattern 'Stop-Process'` returning zero hits. Phase 18.4/18.5 contract preserved.

**Commit message:** `fix(deploy): atomic rename-first prune to prevent half-pruned cache dirs`

### Task 2 — Explicit installed_plugins.json patch (extend Step 9, around lines ~583-598)

**Goal:** Make the deploy authoritatively advance the `spt@cplugs` version pointer instead of trusting `claude plugin install` to do it. The CLI's no-op behavior on "already installed" was the silent failure that let 1.9.8 → 1.9.9 drift hide.

**Steps:**

1. Locate `Write-Step "Step 9: Refresh plugin state via Claude Code CLI"`. Keep this label and the existing `claude plugin install spt@cplugs` invocation block as-is — it stays useful for cache-warm-up and orphan-marker cleanup. The change is additive: a new patch step runs AFTER the CLI call (or after the warning if `claude` isn't on PATH).
2. Compute the values to write:
   - `$installedPluginsPath = Join-Path $HOME '.claude/plugins/installed_plugins.json'` (already declared earlier as `$InstalledPluginsJson` — reuse that variable).
   - `$newInstallPath = $CacheVer` (already computed at the top of step 6-7, points at `~/.claude/plugins/cache/cplugs/spt/<Version>`).
   - `$newGitSha`: run `git -C $Marketplace rev-parse HEAD` inside a `Push-Location` / `Pop-Location` (or use `$Sha` if already captured by step 5 — yes, `$Sha` is set at line 411 when marketplace commit ran, AND at line 375-378 when `-SkipMarketplaceCommit` was passed). When `$SkipMarket` is true (no marketplace dir), fall back to `git -C $RepoRoot rev-parse HEAD` so we still write a meaningful SHA, with a `Write-Warn` noting the SHA comes from the spt repo rather than the marketplace.
   - `$newLastUpdated`: `(Get-Date).ToUniversalTime().ToString('o')` — ISO-8601 with timezone offset, matching the format CC writes (see the debug doc evidence: `lastUpdated: 2026-05-11T01:57:23.957Z`).
3. Read + parse the JSON. If `Test-Path $installedPluginsPath` is false: `Write-Warn` "no installed_plugins.json yet — skipping explicit patch (CLI install above should have created it; assertion in step 10 will catch a true failure)" and skip the patch. If `Get-Content -Raw | ConvertFrom-Json` throws: `Write-Warn` the parse error and skip the patch (same rationale).
4. Patch the object:
   - If `$ipObj.plugins` is null OR `$ipObj.plugins.'spt@cplugs'` is null: this is the first-deploy case. Build a fresh entry as a `[ordered]` hashtable converted to a single-element array. Required keys (mirror CC's schema as observed in the debug doc): `version`, `installPath`, `gitCommitSha`, `lastUpdated`. Use `Add-Member -NotePropertyName 'spt@cplugs' -NotePropertyValue @(...)` on `$ipObj.plugins`, creating `$ipObj.plugins` first if needed via `Add-Member -NotePropertyName 'plugins' -NotePropertyValue ([PSCustomObject]@{})`.
   - Otherwise: `$entry = $ipObj.plugins.'spt@cplugs'[0]`; mutate via `$entry.version = $Version`, `$entry.installPath = $newInstallPath`, `$entry.gitCommitSha = $newGitSha`, `$entry.lastUpdated = $newLastUpdated`. If `$entry` lacks any of these properties (older schema), use `Add-Member -Force -NotePropertyName ... -NotePropertyValue ...` to upsert.
   - Preserve any other top-level keys in `$ipObj` and any other plugin entries in `$ipObj.plugins` — only the `spt@cplugs` entry is touched. The whole-object round-trip through `ConvertFrom-Json` / `ConvertTo-Json -Depth 10` handles this naturally.
5. Serialize + atomic write:
   - `$newRaw = $ipObj | ConvertTo-Json -Depth 10`. The `Depth 10` matters — CC's installed_plugins.json contains nested marketplace metadata; the default depth of 2 would truncate.
   - Atomic write via temp + move: `$tmpPath = "$installedPluginsPath.tmp-$([int][double]::Parse((Get-Date -UFormat %s)))"`; `[System.IO.File]::WriteAllText($tmpPath, $newRaw)`; `Move-Item -LiteralPath $tmpPath -Destination $installedPluginsPath -Force`. The two-step write avoids leaving a truncated file if PowerShell is interrupted mid-write (rare for an interactive script but free to guarantee).
   - Under `-DryRun`: log `[DRY-RUN] patch installed_plugins.json: version=$Version, installPath=$newInstallPath, gitCommitSha=$newGitSha, lastUpdated=$newLastUpdated` and skip the write.
6. Surround the entire patch block in a try/catch. On exception, `Write-Warn` "installed_plugins.json patch failed: $($_.Exception.Message) — the assertion in step 10 will exit nonzero". Do NOT throw here; let step 10's assertion be the single failure point (avoids a confusing double-error path).
7. Log via `Write-Step "Step 9b: Patch installed_plugins.json (canonical pointer)"` immediately before the patch block — a new sub-step number that does NOT collide with existing `Write-Step "Step N: ..."` greps. The "Step 9b" label is intentionally distinct so historical log-grep for `"Step 9:"` (refresh via CLI) still matches the unchanged CLI block.

**Verification:**

- `git diff docs/DEPLOY.ps1` shows a new block reading + patching + writing `installed_plugins.json`, with both update-existing and first-deploy branches.
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'ConvertTo-Json -Depth 10'` returns at least one hit.
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'Move-Item.*-Force.*installed_plugins'` returns at least one hit (the atomic write).
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'Step 9b'` returns at least one hit.
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'JavaScriptSerializer'` returns zero hits (we are explicitly NOT reintroducing the deprecated serializer that was removed previously — comment header at line 578-581 warns against it).
- Dry-run smoke test: `... -DryRun` emits `[DRY-RUN] patch installed_plugins.json: version=...` with all four fields populated.

**Commit message:** `fix(deploy): explicitly patch installed_plugins.json instead of trusting claude CLI no-op`

### Task 3 — Post-deploy version-pointer assertion (new Step 10)

**Goal:** Fail loud on pointer drift. If after step 9 the patch didn't take (CLI overwrote our write, file got truncated, schema changed in a CC update, etc.), the deploy must exit 1 instead of printing the green "Deploy complete" banner.

**Steps:**

1. Insert a new section between the current Step 9 (CLI refresh) and the current "Step 10: Final message". Use `Write-Step "Step 10: Verify installed_plugins.json reflects v$Version"`. The existing "Step 10: Final message" comment header gets renumbered to "Step 11: Final message" — but the user-visible `Write-Host` lines under it ("Deploy complete for v$Version.") do not include a step number, so this rename is invisible to log greps.
2. Under `-DryRun`: log `[DRY-RUN] would re-read $InstalledPluginsJson and assert spt@cplugs version == $Version` and skip the assertion. Dry-run never exits nonzero from this section.
3. Otherwise:
   - `$verifyRaw = Get-Content $InstalledPluginsJson -Raw -ErrorAction Stop`
   - `$verifyObj = $verifyRaw | ConvertFrom-Json -ErrorAction Stop`
   - `$verifyEntry = $verifyObj.plugins.'spt@cplugs'`
   - Three independent checks, each with a precise error message:
     a. If `$verifyEntry -eq $null`: `[Console]::Error.WriteLine("ASSERTION FAILED: installed_plugins.json has no spt@cplugs entry after deploy"); exit 1`.
     b. If `$verifyEntry[0].version -ne $Version`: `[Console]::Error.WriteLine("ASSERTION FAILED: installed_plugins.json shows spt@cplugs version=$($verifyEntry[0].version), expected $Version"); exit 1`.
     c. If `$verifyEntry[0].installPath -ne $CacheVer`: `[Console]::Error.WriteLine("ASSERTION FAILED: installed_plugins.json shows installPath=$($verifyEntry[0].installPath), expected $CacheVer"); exit 1`.
   - Each failure writes to stderr via `[Console]::Error.WriteLine` so the message survives both shell redirects and CI capture. `exit 1` ensures non-zero process exit.
   - On success: `Write-SubStep "Pointer verified: spt@cplugs version=$Version installPath=$CacheVer"`.
4. Update `docs/DEPLOY.md`:
   - In "What it does (in order)" (around line 17-26), add a new numbered item 8 after the current item 7 (`claude plugin install spt@cplugs`) describing the explicit `installed_plugins.json` patch + assertion: `8. **Explicitly patches `~/.claude/plugins/installed_plugins.json`** to set the `spt@cplugs` entry's `version`, `installPath`, `gitCommitSha`, and `lastUpdated`. The CLI call above is a no-op when the plugin is already installed (it only touches `lastUpdated`), so this step is the authoritative pointer flip. Atomic write via temp file + `Move-Item -Force`.` Then add item 9: `9. **Re-reads installed_plugins.json and asserts `spt@cplugs.version == <Version>`**. Any mismatch exits the script with status 1; the green "Deploy complete" banner never prints on drift.` Renumber the existing "Prints a reminder to run `/reload-plugins`" to step 10.
   - In "What it does NOT do" (around line 30-38), no change needed — the kill-processes prohibition stays.
   - In the script docstring `.DESCRIPTION` block (lines 11-30), update the step sequence narrative to include the new "Patch installed_plugins.json" and "Verify pointer" steps. Keep the prose under 80 chars per line to match existing style.

**Verification:**

- `git diff docs/DEPLOY.ps1` shows the new "Step 10: Verify..." block and three `ASSERTION FAILED` exit paths.
- `git diff docs/DEPLOY.md` shows the new numbered items 8 + 9 and renumbered item 10.
- `Select-String -Path docs/DEPLOY.ps1 -Pattern 'ASSERTION FAILED'` returns exactly 3 hits.
- `Select-String -Path docs/DEPLOY.ps1 -Pattern '\[Console\]::Error\.WriteLine'` returns at least 3 hits.
- Dry-run smoke test: `... -DryRun` emits the `[DRY-RUN] would re-read ... and assert` line and exits 0.
- Negative manual test (documented, not automated in this plan): if the user manually edits `installed_plugins.json` after the script completes to revert `spt@cplugs.version` to an older value and re-runs the deploy with no version bump (`powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1` against an already-current `plugin.json`), the script should still succeed because step 9b re-writes the pointer. If the user then somehow corrupts the file mid-step-10 (impossible under normal use, but instructive for understanding the gate), step 10 catches it and exits 1.

**Commit message:** `fix(deploy): assert installed_plugins.json reflects deployed version, exit 1 on drift`

</tasks>

<security>

**Sharing-violation safety:** The atomic-rename prune (Task 1) is the entire point of this hardening pass. `Rename-Item` on Windows holds the source directory's parent lock just long enough to flip the entry; if any descendant file is open with a sharing-deny-rename mode (which is rare for code execution — Windows `CreateProcess` does NOT by default deny rename of an enclosing directory), the rename either fully succeeds or fully fails. We rely on the documented NTFS semantics: a rename of a directory containing an in-use file succeeds as long as no handle holds the directory itself open with `DELETE` denied. In the failure case (Phase 18.4/18.5 handoff target with file mapping that does deny rename), the directory stays put with its original semver name AND its original contents — but the deploy proceeds, and the carry-over cleanup pass in the next deploy will retry. Either outcome (renamed + maybe-deleted, or untouched) preserves the resolver-visibility invariant: no half-pruned semver dir is ever exposed.

**No process kills, no force-unlocks:** Phase 18.4/18.5 handoff is the hard invariant. The debug doc proposes `Stop-Process -Force` against owl.exe whose path resolves into prune candidates as a "permanent fix" bullet — this plan explicitly rejects that path. Killing a Psyche wrapper mid-migration strands its `wrapper-state.json`, leaks its SQLite spool handle, and can corrupt session state. The atomic-rename approach achieves the same correctness goal (CC never resolves to stale skills) without touching processes. Task 1's verification includes a `Select-String -Pattern 'Stop-Process'` grep that must return zero hits — a static check that the prohibition is honored.

**JSON injection safety:** PowerShell's `ConvertFrom-Json` parses untrusted JSON into a `PSCustomObject` graph; property access uses property-name resolution, not string interpolation, so a malicious `version` value inside `installed_plugins.json` (e.g., `"$(rm -rf /)"`) cannot cause command execution when later interpolated into a `Write-SubStep` log line because `Write-SubStep`'s argument is a string parameter, not a script block. `ConvertTo-Json -Depth 10` produces well-formed escaped output regardless of input string content. The temp-file + `Move-Item -Force` write is non-clobbering in the sense that any concurrent reader of the path sees either the old or new file but never a partial one (Windows `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` is atomic at the directory-entry level). No `Invoke-Expression`, no string-concatenation-into-eval, no shell-out with interpolated JSON values.

**Path-traversal in installPath:** The new `installPath` value written into the JSON is `$CacheVer`, which is derived from `Join-Path $HOME '.claude/plugins/cache/cplugs/spt'` + `$Version`, where `$Version` comes from `plugin.json`'s validated semver (the `-Bump` path enforces `^(\d+)\.(\d+)\.(\d+)$` at line 176; the non-bump path inherits whatever is already in `plugin.json` but a malicious plugin.json would already be a worse threat than this script could introduce). No user-supplied input flows into a filesystem path here.

**Atomic temp-file naming:** The temp file uses unix-timestamp suffix to avoid collisions across rapid re-runs. There is a theoretical TOCTOU race where two deploys started in the same second could both compute the same `$tmpPath`, but `Move-Item -Force` resolves it (last-writer wins on the destination, and the temp files themselves are written via `[System.IO.File]::WriteAllText` which creates-or-truncates; the only loss is one of the two deploys' patches, and step 10's assertion catches that loss). Acceptable trade-off — concurrent deploys are not a supported workflow.

</security>

<verification>

**Plan-level acceptance (all three required to consider the work done):**

1. `git diff docs/DEPLOY.ps1` shows:
   - The original `foreach ($child in $pruneCandidates) { ... Remove-Item -Recurse -Force -LiteralPath $child.FullName ... }` block REPLACED with rename-first logic that uses `Rename-Item` against `.pending-prune-<name>-<unix-ts>` and only then attempts `Remove-Item` on the renamed path.
   - A new carry-over cleanup pass for pre-existing `.pending-prune-*` dirs at the start of the prune section.
   - A new "Step 9b" block that reads, patches, and atomically writes `~/.claude/plugins/installed_plugins.json` with `version` / `installPath` / `gitCommitSha` / `lastUpdated`.
   - A new "Step 10: Verify ..." block with three `ASSERTION FAILED` exit-1 paths writing to stderr.
   - `git grep 'Stop-Process' docs/DEPLOY.ps1` returns nothing (Phase 18.4/18.5 invariant preserved).

2. `git diff docs/DEPLOY.md` shows the "What it does (in order)" list extended with the explicit JSON patch and the assertion as numbered items, and the docstring updated to match. No contradiction between script behavior and doc prose.

3. Manual end-to-end test (the user runs this; not automated in the plan because the deploy mutates host state outside the repo):

   a. **Clean-slate dry-run:**
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -DryRun
      ```
      Expected: prints `[DRY-RUN] rename ...` lines (not `[DRY-RUN] would remove ...`), prints `[DRY-RUN] patch installed_plugins.json: version=...`, prints `[DRY-RUN] would re-read ... and assert`, exits 0. No filesystem changes anywhere.

   b. **Real deploy with bump:**
      ```powershell
      powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -Bump patch
      ```
      Expected on success: green `Deploy complete for v<NewVersion>` banner; `Pointer verified:` line printed by step 10; `~/.claude/plugins/installed_plugins.json` opened post-deploy shows `spt@cplugs[0].version == <NewVersion>` and `installPath` ending in the new version dir; `~/.claude/plugins/cache/cplugs/spt/` contains the new version dir AND the previous version dir, and any older versions are either fully gone OR exist only as `.pending-prune-*<old-ver>-*` (NEVER as a half-empty `<old-ver>/` with `skills/` removed). `/reload-plugins` reminder printed last.

   c. **Pointer-drift simulation (negative test):**
      Manually edit `~/.claude/plugins/installed_plugins.json` to set `spt@cplugs[0].version` to a string that does not match `plugin.json`. Re-run the deploy without `-Bump`. Expected: step 9b rewrites the entry, step 10's assertion passes, deploy completes. Now simulate a step-9b failure by setting the JSON file read-only after step 9b would have run (race-condition-style; in practice the easiest way is to break step-9b by temporarily renaming the variable used to write the file): the assertion in step 10 must exit 1 with `ASSERTION FAILED: ...` on stderr, the green banner must NOT print, and `$LASTEXITCODE` from the shell must be 1.

   d. **Handoff preservation (mandatory observation):** during the real deploy at step (b), start a Live agent with `$LIVE start <id>` BEFORE running the deploy, leave it running. After the deploy completes, the agent's Psyche wrapper must still be alive (`$OWL list-perches` shows it), its perch must still respond, and within ~60s it must self-migrate to the new version's owl.exe (visible via `Get-Process owl | Select-Object Path` showing the new version's path). If any running owl.exe gets killed by the deploy or any Psyche wrapper exits, the deploy script has violated the Phase 18.4/18.5 contract and the fix must be reverted.

**Out-of-scope verifications (explicitly NOT in this plan):**

- Automated PowerShell unit tests for DEPLOY.ps1. The script has no `Pester` test harness in the repo; introducing one is a separate phase. Manual smoke + assertion is sufficient for this scope.
- Cross-version deploy stress test (deploy → live agent → deploy again → live agent migrates). The handoff observation in test (d) is the minimum bar; full multi-cycle stress testing belongs to Phase 18.x verification work, not deploy-script hardening.
- Cleanup of any existing half-pruned 1.9.7 cache dir on the user's machine. That's a manual fix per the debug doc's "Immediate" section; this plan only ensures the failure mode cannot recur on future deploys.

</verification>
