---
quick_id: 260510-vng
slug: deploy-ps1-hardening-atomic-prune-and-po
status: complete
completed: 2026-05-11
commits:
  - b38ea4d
  - 1e3f9f6
  - 7c6f115
  - 8b64176
files_changed:
  - docs/DEPLOY.ps1
  - docs/DEPLOY.md
  - .planning/STATE.md
---

# Quick Task 260510-vng — DEPLOY.ps1 hardening

## What changed

All edits stayed inside `docs/DEPLOY.ps1` and `docs/DEPLOY.md`. No Rust changes, no plugin-side changes, no `plugin.json` or `Cargo.toml` edits, no process kills. A repo-wide grep for `Stop-Process` in `docs/DEPLOY.ps1` returns zero hits after this work, which is the static check that proves the Phase 18.4/18.5 seamless-handoff contract was preserved across the full hardening pass.

`docs/DEPLOY.ps1` grew by roughly two hundred lines, structured as three additive changes. The prune section that runs after the new version is synced into the cache no longer issues a recursive force-delete against directories whose names match the plain-semver regex. Each prune candidate is renamed to a `.pending-prune-<original-name>-<unix-timestamp>` shape, then the recursive delete is attempted against the renamed path. The rename either fully succeeds or fully fails at the NTFS directory-entry level, and the renamed name is no longer semver-shaped, so Claude Code's skill resolver cannot land on stale skills inside a half-pruned older version even if the second-stage delete is blocked by a sharing violation on the still-mapped `owl.exe`. A new carry-over cleanup pass at the start of the prune section sweeps any `.pending-prune-*` directories left behind by prior deploys, tolerating per-directory failure so a stubborn lock just persists another cycle. The keep-set construction, prune-candidate enumeration, and all existing log lines (`Prune candidates:`, `No prune candidates`, `First deploy detected`, `Cache root does not exist yet`) were preserved unchanged so historical log-grep tooling continues to match.

A new Step 9b runs after the existing `claude plugin install spt@cplugs` invocation. The existing Step 9 is retained but explicitly demoted: its label and its CLI call stay for cache-warm-up and `.orphaned_at` marker cleanup, but the comment header now states that the CLI is a no-op when the plugin is already installed at any version, which is exactly the silent failure that left `installed_plugins.json` pinned to 1.9.8 across the 1.9.9 deploy. Step 9b reads `~/.claude/plugins/installed_plugins.json`, parses it with `ConvertFrom-Json`, mutates or upserts the four pointer fields on the `spt@cplugs[0]` entry (or creates a fresh single-element array for the first-deploy case), serializes the whole graph with `ConvertTo-Json -Depth 10` so nested marketplace metadata survives the round trip, and writes the result atomically through a temp file whose path carries a unix-timestamp suffix followed by `Move-Item -LiteralPath ... -Destination $InstalledPluginsJson -Force`. Failures inside the patch block surface as a `Write-Warn` rather than throwing, because the new Step 10 assertion is the single point of truth for whether the pointer actually advanced. The `gitCommitSha` value uses `$Sha` from the marketplace HEAD captured in Step 5 when the marketplace was not skipped, otherwise it falls back to the spt repo HEAD with a warning. PowerShell's deprecated `JavaScriptSerializer` is explicitly not reintroduced — both surviving mentions in the file are negative-reference comments warning future maintainers off of it.

The new Step 10 verifies that the patch actually took effect. The block re-reads `installed_plugins.json` from disk after Step 9b has finished, parses it, and runs three independent assertions: that the `spt@cplugs` entry exists at all, that its first element's `version` equals the deployed `$Version`, and that its first element's `installPath` equals the freshly-computed `$CacheVer`. Each failure writes `ASSERTION FAILED: <specific drift description>` to stderr via `[Console]::Error.WriteLine` and calls `exit 1`. The previous Step 10 (the green `Deploy complete` banner) is renumbered to Step 11 in the comment header, but its user-visible `Write-Host` lines never carried a step number so log-grep tooling is unaffected. Under `-DryRun` the assertion block emits a `[DRY-RUN] would re-read ... and assert` line and exits zero, matching the dry-run semantics of every other mutating section. The script's `.DESCRIPTION` block was extended in parallel to describe the new Step 8 (explicit patch) and Step 9 (verification), renumbering the `/reload-plugins` reminder to step 10.

`docs/DEPLOY.md` was updated to mirror the script behavior in its `What it does (in order)` numbered list. Item 7's prose was rewritten to say plainly that `claude plugin install spt@cplugs` is no longer the version-pointer flip and that its no-op behavior on `already installed` was the failure mode in the 1.9.8 → 1.9.9 deploy. Two new numbered items 8 and 9 describe the explicit `installed_plugins.json` patch and the post-deploy assertion, and the prior item 8 (`/reload-plugins` reminder) is renumbered to item 10. The `What it does NOT do` list and the `kill running owl.exe processes` prohibition were left unchanged because the Phase 18.4/18.5 contract still holds with the new behavior.

## Verification performed in-session

Each task's per-task verification block was checked before committing. After Task 1 a grep for the literal `Remove-Item -Recurse -Force -LiteralPath $child.FullName` returned zero hits, and a grep for `Rename-Item` and `pending-prune-` confirmed the new atomic-rename logic. After Task 2 the file contained `ConvertTo-Json -Depth 10` in code, a `Move-Item -LiteralPath $tmpPath -Destination $InstalledPluginsJson -Force` invocation, a `Write-Step "Step 9b: ..."` label, and zero `JavaScriptSerializer` introductions outside of negative-reference comments. After Task 3 the file contained exactly three `ASSERTION FAILED:` exit-one paths, three `[Console]::Error.WriteLine` invocations in code (a fourth hit is a comment-block mention), and continued to have zero `Stop-Process` hits. The combined `git diff --stat` across all three tasks showed only `docs/DEPLOY.ps1` and `docs/DEPLOY.md` modified — no Rust, no plugin, no scope creep.

The dry-run smoke tests called for in the plan's per-task verification were intentionally not executed in this agent run. The harness rule forbids running `docs/DEPLOY.ps1` (user-only deploys per project memory `feedback_deploy_all_files.md`), and a `-DryRun` still walks the same prune-candidate enumeration and `installed_plugins.json` parse paths against the user's live cache state. Static verification through the grep contracts above is the substitute and was sufficient to confirm each task's structural acceptance criteria.

## Manual test plan deferred to user

Three observations need to happen on the user's machine before this hardening can be declared end-to-end correct, and they all depend on a real deploy cycle.

The first is a `-DryRun` invocation: `powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -DryRun`. The prune section should emit `[DRY-RUN] rename ...` lines for any prune-candidate directories rather than the prior `[DRY-RUN] would remove ...` lines. Step 9b should emit `[DRY-RUN] patch installed_plugins.json: version=..., installPath=..., gitCommitSha=..., lastUpdated=...` with all four fields populated. Step 10 should emit `[DRY-RUN] would re-read ... and assert ...`. No filesystem mutations should occur and the script should exit zero.

The second is a real `-Bump patch` deploy on the next cycle. Expected outcomes are the green `Deploy complete for v<NewVersion>` banner, a preceding `Pointer verified: spt@cplugs version=<NewVersion> installPath=...` line emitted by Step 10, an `installed_plugins.json` whose `spt@cplugs[0].version` field reflects the newly-deployed version when inspected afterwards, and a `cache/cplugs/spt/` directory that contains the new version and the previous version dir but contains either no older versions at all or only `.pending-prune-*` named carry-overs — never a half-empty semver-named directory.

The third is a handoff-preservation observation. Before running the real deploy, a Live agent should already be running. After the deploy finishes, the agent's Psyche wrapper should still be alive, its perch should still respond to `$OWL list-perches`, and within about sixty seconds the agent should self-migrate to the new version's `owl.exe` (visible via `Get-Process owl | Select-Object Path`). Any `owl.exe` getting killed by the deploy, or any Psyche wrapper exiting during the deploy, would indicate a Phase 18.4/18.5 contract violation and would require reverting this hardening pass.

## Separation from the in-session immediate fix

A separate immediate-fix sequence was applied earlier in this same session to restore the user's local state from the 26-06 regression. That sequence force-flipped `installed_plugins.json` to v1.9.9 and restored `cache/cplugs/spt/1.9.7/skills/` from the 1.9.8 cache where the partial prune had deleted it. None of those actions are part of this hardening plan. This plan's scope is preventing recurrence at the script level. The user's machine state was repaired by the immediate fix; the script changes in this task ensure the same partial-cleanup-plus-silent-CLI-noop pattern cannot produce another half-pruned cache directory or another stale version pointer on future deploys.

## Deviations from plan

None of substance. One minor adjustment: the regex-based verification of `Move-Item -Force` adjacent to a literal `installed_plugins` substring would not match because the script uses the `$InstalledPluginsJson` variable rather than the literal filename inline. The semantic equivalent `Move-Item -LiteralPath $tmpPath -Destination $InstalledPluginsJson -Force` is present at the expected location and is the correct PowerShell idiom because the path is parameterized at the top of Step 6. The plan's intent (verify the atomic write goes to `installed_plugins.json` via `Move-Item -Force`) is satisfied.

## Commits

| SHA | Subject |
|-----|---------|
| `b38ea4d` | fix(deploy): atomic rename-first prune to prevent half-pruned cache dirs |
| `1e3f9f6` | fix(deploy): explicitly patch installed_plugins.json instead of trusting claude CLI no-op |
| `7c6f115` | fix(deploy): assert installed_plugins.json reflects deployed version, exit 1 on drift |
| `8b64176` | docs(state): record quick task 260510-vng (DEPLOY.ps1 hardening) complete |
