# Phase 34: Version-Change Changelog - Research

**Researched:** 2026-05-17
**Domain:** Claude Code Stop hook semantics, CHANGELOG.md parsing, Windows atomic-write races, DEPLOY.ps1 stub-injection
**Confidence:** HIGH (primary research blocker D-04 RESOLVED with multi-source agreement)

---

## Summary

- **Q1 / D-04 GREEN:** Stop hook `decision:"block"` + `reason` propagates `reason` text to Claude as continuation instruction — Claude generates a new assistant turn responding to it. No silent abort. Multi-source agreement (Anthropic schema text "`reason` tells Claude how to proceed", disler hooks-mastery, claude.com blog, claudefa.st). The architectural choice in D-04 is sound; no fallback to PreToolUse / UserPromptSubmit is required.
- **Q2 nuance:** Claude CAN invoke AskUserQuestion immediately on re-engagement (the block IS the new-turn trigger; no user prompt needed first). The `reason` text drives Claude's response, and Claude is free to call any tool — exactly the pattern that "stop hook forces task completion" examples in the ecosystem rely on.
- **Stop hook has NO `hookSpecificOutput` schema** — confirmed. Only top-level `decision` + `reason` + universal fields (`continue`, `stopReason`, `suppressOutput`, `systemMessage`). The existing `hook_idle.rs:1-5` comment is accurate. Block-body content MUST live inside `reason` verbatim. Payload size budget: `reason` is a single string; no documented hard cap but practical limit aligns with the 10,000-char JSON-output cap. Our payload is tiny (a few hundred chars).
- **`stop_hook_active` infinite-loop guard:** Industry-standard `Stop` input flag. Set to `true` on Claude's re-engagement after a previous block in the same continuation chain. Our hook MUST short-circuit on `stop_hook_active==true` regardless of version mismatch, as a defensive guard. With D-05 sentinel pre-write the version-mismatch path naturally won't re-fire, but the guard prevents pathological cases (e.g., sentinel write failure → mismatch persists → infinite block loop).
- **`$CLAUDE_PLUGIN_ROOT` confirmed available** to plugin-bundled hooks at invocation. Documented in Claude Code hooks reference.
- **Plan boundary recommendation:** 3 plans, refined boundary below (Q8). Plan 01 = amendment-commit + CHANGELOG.md genesis + DEPLOY.ps1 (doc/script only, no Rust). Plan 02 = Rust hook + sentinel + parser + `version-remind` subcommand + unit tests. Plan 03 = block-body authoring + integration test on synthesized version transition.

**Primary recommendation:** Proceed with the design exactly as captured in CONTEXT.md D-01..D-12. The single research blocker (D-04) resolves favorably; no architectural pivot required.

---

## User Constraints (from CONTEXT.md)

### Locked Decisions (D-01..D-12 — verbatim authority)

- **D-01:** Stop hook (`hook_idle.rs`), not SessionStart.
- **D-02:** Predicate = `owl_id` resolves in `hook_idle.rs::run()` (piggyback existing perch resolution).
- **D-03:** Recursion guard reuses `OWL_ECHO_COMMUNE` env-var skip (hook_idle.rs:64-69 pattern).
- **D-04:** Block emission via top-level `decision:"block"` + `reason:"<spt-version-changelog>...</spt-version-changelog>"`. RESEARCH-CONFIRMED.
- **D-05:** Sentinel write site = same `run()` top, BEFORE returning the block. Atomic via `atomic_write_string`. Default behavior: writes the NEW version. Rollback only via explicit `version-remind`.
- **D-06:** Fire-once cadence = once per version transition (sentinel pre-write enforces this).
- **D-07:** Payload shape = `{old, new, old_date, new_date, step_count}`.
- **D-08:** AskUserQuestion 4-option shape: `Yes, full changelog` / `Yes, highlights only` / `Remind me later` / `Skip`. Block body MUST surface the exact `$OWL version-remind <old>` invocation string verbatim.
- **D-09:** Repo-root `CHANGELOG.md` authored one-shot at Plan 01 start from `git log --grep='chore: bump'`. No `owl changelog backfill` subcommand.
- **D-10:** Forward maintenance = DEPLOY.ps1 `-Bump` stub injection + commit gate (combined: append `TODO: changelog entry` stub uncommitted; next `-Bump` aborts if marker still present).
- **D-11:** DEPLOY.ps1 sync target = `{Marketplace}/plugin/spt/CHANGELOG.md` (sibling to plugin.json). Resolves at hook time via `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md`.
- **D-12:** Amend VERS-02, VERS-04, VERS-06, VERS-08, VERS-09 + ROADMAP.md SC1/SC2/SC5 per the rewordings in CONTEXT.md.

### Claude's Discretion (planner picks)

- `<spt-version-changelog>` block exact XML shape (planner picks JSON-as-text vs child elements).
- Block payload: embed full excerpt vs Claude-reads-at-yes-time (researcher leans toward Claude-reads, see Q11 below).
- CHANGELOG.md parser regex tightness (`[Unreleased]` reject, link reference lines ignored) — locked in test corpus below.
- `Yes, highlights only` extraction policy framing — final wording in Plan 02 or 03.
- Plan split (3 plans) — final boundary at planner's discretion; researcher recommendation in Q8.

### Deferred Ideas (OUT OF SCOPE)

- `owl changelog backfill` subcommand
- `/spt:changelog` skill
- Sentinel with reminder flag (richer schema)
- Telemetry for AUQ option distribution
- `Yes, highlights only` rubric formalization (H3 subsections)
- PreToolUse fallback (not needed — D-04 confirmed)
- UserPromptSubmit revisit
- Block payload embedded full excerpt — researcher recommends NOT embedding (see Q11)

---

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| VERS-01 | `$SPT_HOME/last-seen-version.json` sentinel sibling to `owlery/` | `src/common/owlery.rs:18-32` spt_home resolution verified; sentinel path = `spt_home().join("last-seen-version.json")` |
| VERS-02 | Stop hook compares `env!("CARGO_PKG_VERSION")` against sentinel (AMENDED per D-12) | hook_idle.rs:14-43 emission site verified; CARGO_PKG_VERSION precedent at poll.rs:75, setup.rs:35, stop.rs:199 |
| VERS-03 | First-install silent write | Sentinel-missing path: read returns None/Err → atomic_write_string current version, return without block emission |
| VERS-04 | AskUserQuestion 4-option shape (AMENDED per D-12) | Block-body inline instructions to Claude; AUQ invoked by Claude on re-engagement |
| VERS-05 | Atomic write via `atomic_write_string` | Verified at src/common/owlery.rs:248; tmp + rename; NTFS same-volume safe |
| VERS-06 | Multi-version skip (AMENDED per D-12) — 4-option AUQ applies uniformly | `step_count` field in payload; Yes-full renders all H2 sections verbatim |
| VERS-07 | Repo-root `CHANGELOG.md` synced via DEPLOY.ps1 to plugin meta dir | DEPLOY.ps1 sync step extended; resolves to `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` per D-11 |
| VERS-08 | AUTO-03 vs VERS-04 coexistence (AMENDED per D-12) — separate hooks | AUTO-03 is SessionStart (plugin_session_start.rs); VERS-04 is Stop (hook_idle.rs). No collision possible. |
| VERS-09 | Single write site, no in-handoff race (AMENDED per D-12) | hook_idle.rs::run() top after perch resolution + OWL_ECHO_COMMUNE guard. Stop hooks do not fire mid-handoff. |

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Version-mismatch detection | Rust hook (`hook_idle.rs`) | — | Compile-time `env!("CARGO_PKG_VERSION")` is the only handoff-safe source per Out-of-Scope §3 |
| Sentinel read/write | Rust hook + `version-remind` subcommand | `atomic_write_string` (owlery.rs:248) | Both write sites share the same helper; rollback is a discrete subcommand for explicit user intent |
| CHANGELOG.md parse | Rust hook | — | "Rust hook does it once" chosen explicitly in Discussion Log Round 2 |
| Block payload composition | Rust hook | — | Hook builds `{old, new, old_date, new_date, step_count}` from parser output; embeds verbatim into `reason` |
| AUQ render + branching | Claude (block-body instructions) | — | Stop hook has no AUQ-emission channel; Claude invokes AUQ on re-engagement |
| `Yes, full changelog` render | Claude reads `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` | — | See Q11 recommendation — block payload stays small |
| `Yes, highlights only` synthesis | Claude (discretion + framing) | — | D-08; no fixed rubric |
| `Remind me later` rollback | `$OWL version-remind <old>` subcommand | `atomic_write_string` | Bare positional arg; ~10-line implementation |
| CHANGELOG.md authorship (genesis) | Human (Plan 01 step 1) | git log skeleton | One-shot, never auto-regenerated |
| Forward CHANGELOG.md maintenance | DEPLOY.ps1 `-Bump` stub + commit gate | Human fills | Combined enforcement: stub-append + unchanged-abort |

---

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `serde_json` | (existing, bundled with `preserve_order`) | Sentinel JSON read/write | Already in tree; minimal `{"version":"X.Y.Z"}` shape needs only string round-trip |
| `clap` | (existing) | `version-remind` subcommand registration | Already in tree; bare positional arg slots in alongside existing owl subcommands |

### Supporting

| Library | Purpose | When to Use |
|---------|---------|-------------|
| `std::fs::read_to_string` | CHANGELOG.md slurp at hook time | One-time read; file ~kB scale |
| `std::env::var` | `$CLAUDE_PLUGIN_ROOT` resolution | Hook fallback path: walk up from hook script if env var unset (defensive) |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Manual H2 regex parser | `pulldown-cmark` markdown crate | Adds dep weight; Phase 34 H2-only parsing is ~10 LOC of regex — overkill |
| `include_str!` embed CHANGELOG.md | Runtime read from `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` | Explicitly rejected in Discussion Round 1: binary bloat with history |
| `owl changelog backfill` subcommand | One-shot manual author | Discussion Round 1: deferred (D-09) |

**Verified versions:** No new crates required. All work uses already-installed `serde_json`, `clap`, and `std` — no version drift risk.

---

## Per-Question Findings

### Q1 (CRITICAL): Stop hook `decision:"block"` + `reason` propagation

**Status:** GREEN — RESOLVED.

**Finding:** When a Stop hook emits `{"decision":"block","reason":"..."}`, the `reason` text is propagated to Claude as continuation instruction. Claude does NOT silently abort — Claude generates a new assistant turn responding to the reason text, exactly as if the reason had been delivered as a system message instructing Claude to continue working.

**Sources:**
- Anthropic's hooks reference `~/.claude/reference_docs/claude-code-hooks.md` (lines 389-399): Stop hook output is `{"decision":"block","reason":"Reason to block completion"}` (note the schema text positions `reason` as actionable instruction).
- [Claude Code Hooks Mastery (disler)](https://github.com/disler/claude-code-hooks-mastery): "Yes, when `decision: "block"` is returned with a reason field in the Stop hook, the reason text is fed back to Claude as a continuation instruction." `[CITED]`
- [Anthropic blog: How to configure hooks](https://claude.com/blog/how-to-configure-hooks): documents `decision:"block"` with `reason` as "shown to Claude" `[CITED]`
- [Claudefa.st Stop Hook guide](https://claudefa.st/blog/tools/hooks/stop-hook-task-enforcement): "When you use `decision: block` in a Stop hook, it automatically prompts Claude with the reason provided, which is crucial because the reason field is fed back to Claude" `[CITED]`
- [Steve Kinney AI Tools course](https://stevekinney.com/courses/ai-development/claude-code-hook-control-flow): confirms same mechanic
- Practical evidence: the ecosystem "force task completion" pattern (e.g. "must pass tests before stopping") relies on this exact propagation. If `reason` did not reach Claude, that entire pattern category would be inoperative — and it is documented as working across multiple guides.

**Rendered shape on Claude's side:** Claude sees the reason text as the new continuation context. The block IS the new-turn trigger. No user prompt is required. Claude's response then renders the block body inline, then proceeds to call AskUserQuestion (matching the pattern other Stop-hook-driven flows use).

**Counter-evidence considered:** One WebFetch response claimed the reason is "shown to the user only, not fed back to Claude" — this was anomalous and contradicted by 4+ other sources including the official hooks reference. Discounted as parsing artifact.

**No fallback required.** PreToolUse and UserPromptSubmit alternatives in Deferred Ideas remain dormant.

### Q2: AskUserQuestion invocation from blocked Stop context

**Status:** GREEN.

**Finding:** Once Claude re-engages with the `reason` text in context, Claude can immediately invoke AskUserQuestion in the same turn — without waiting for a new user prompt. The block IS the new-turn trigger; the new turn is bounded by Claude's response, which can include any tool call.

**Sources:**
- Inference from Q1: if reason drives Claude's new turn, Claude is free to invoke tools per the normal assistant-turn model.
- Ecosystem pattern: "stop hook → forced continuation → tool call → new stop hook" loop is well-attested in the disler/claudefa.st examples. The `stop_hook_active` flag exists precisely because Claude can and will fire tools in the forced-continuation turn.

**Implication for D-08:** The block body's inline instructions to "render the AUQ now with these 4 options" will be honored on the same turn — AUQ fires immediately, before any new user message.

**Caveat:** One Anthropic-docs WebFetch snippet suggested "Claude would need a new user prompt OR the hook to provide additionalContext" — but since the Stop hook does NOT support `additionalContext` (Q3) and the ecosystem clearly demonstrates Claude responding to `reason`, the practical conclusion is that `reason` IS the new context channel for Stop hooks. The disler/claudefa.st consensus wins.

### Q3: Stop hook output schema — `hookSpecificOutput.additionalContext`?

**Status:** Confirmed NEGATIVE.

**Finding:** Stop hook output supports ONLY top-level `decision` + `reason` + universal fields (`continue`, `stopReason`, `suppressOutput`, `systemMessage`, `terminalSequence`). NO `hookSpecificOutput` envelope. NO `additionalContext` field.

**Sources:**
- `~/.claude/reference_docs/claude-code-hooks.md` lines 389-399 (Stop section) — only `decision` + `reason` documented.
- Anthropic hooks reference page (WebFetch verified): "Stop is explicitly listed as supporting only `decision` and `reason` at the top level—not `hookSpecificOutput`."
- Existing `src/owl/hook_idle.rs:1-5` comment is accurate and predates our research.

**Implication:** ALL block-body content (the `<spt-version-changelog>` XML, the AUQ instructions, the `$OWL version-remind <old>` invocation string) MUST live inside the single `reason` string. This is fine — payload is tiny, well under any practical limit.

### Q4: `$CLAUDE_PLUGIN_ROOT` availability inside Stop hook process

**Status:** GREEN.

**Finding:** `$CLAUDE_PLUGIN_ROOT` is set by Claude Code on the spawned hook process for ALL plugin-bundled hooks. Documented in the hooks reference (line 607) and verified via authoritative Anthropic docs.

**Sources:**
- `~/.claude/reference_docs/claude-code-hooks.md` line 607: "`$CLAUDE_PLUGIN_ROOT` -- Plugin directory" listed under "Environment Variables Available in Hooks".
- Anthropic hooks page (WebFetch verified): "Both forms support the same path placeholders, and both export them as the environment variables `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, and `CLAUDE_PLUGIN_DATA` on the spawned process."

**Implication:** Hook resolves CHANGELOG.md path as:
```rust
let plugin_root = std::env::var("CLAUDE_PLUGIN_ROOT")
    .map_err(|_| "CLAUDE_PLUGIN_ROOT must be set by Claude Code")?;
let changelog_path = std::path::Path::new(&plugin_root).join("CHANGELOG.md");
```

**Defensive fallback:** If `$CLAUDE_PLUGIN_ROOT` is unset (e.g., direct invocation in tests, or theoretical CC bug), the hook should fail soft — skip emission, do not panic. Tests cover both branches.

### Q5: `$OWL` env var availability inside AUQ-renderer Claude turn

**Status:** GREEN.

**Finding:** `$OWL` is injected into `~/.claude/settings.json` by SPT's SessionStart hook (per CLAUDE.md §"Env vars: `$OWL` and `$LIVE` are auto-injected into `~/.claude/settings.json` at SessionStart"). Settings env vars persist for the full session, across all turns — Bash tool invocations resolve `$OWL` to the absolute owl.exe path.

**Sources:**
- `CLAUDE.md` §Technology Stack: "Env vars: `$OWL` and `$LIVE` are auto-injected into `~/.claude/settings.json` at SessionStart."
- Existing skill convention: every `/spt:*` skill (e.g., `commune/SKILL.md`, `signoff/SKILL.md`) uses `$OWL deliver` / `$LIVE pick-spec` without first having to resolve the path — confirms the env var is honored at tool-invocation time.

**Implication:** Claude can call `$OWL version-remind 1.10.9` in a Bash tool invocation directly. The block body should surface the exact invocation string verbatim per D-08.

### Q6: `atomic_write_string` Windows semantics under concurrent writers

**Status:** GREEN — acceptable for our use case.

**Finding:** Rust's `std::fs::rename` on Windows uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` semantics. On NTFS same-volume renames (our case — both source `.tmp` and destination live under `$SPT_HOME`), the rename is atomic with respect to the file pointer: readers either see the old file or the new file, never a torn read.

**Concurrent writers behavior:** Last-writer-wins. If two Stop hooks race to write `last-seen-version.json` (e.g., two concurrent spt-bearing agents both transitioning v1.10.9 → v1.10.10 simultaneously), both write the same value `{"version":"1.10.10"}` — the final on-disk content is identical regardless of which rename "won". No torn reads possible.

**Race-condition window:** Between the two `fs::write(&tmp, body)?` calls and the two `fs::rename` calls, there is a window where both `.tmp` files exist. The temp filename is shared (`last-seen-version.json.tmp` — see `atomic_write_string` source at owlery.rs:249) which CAN cause the second writer to fail with `ERROR_SHARING_VIOLATION` or overwrite the first's `.tmp`. Per existing call-site pattern (`write_last_commune_epoch` in owlery.rs:264) this is treated as best-effort: errors are swallowed at the caller layer. Our Phase 34 hook should match this — wrap `atomic_write_string` in a best-effort pattern; on failure, log to stderr and continue. Worst case: sentinel doesn't update this cycle → block re-fires on next Stop in same session (degraded but not broken).

**Sources:**
- `src/common/owlery.rs:238-247` — existing comment asserts NTFS+POSIX same-volume atomic rename.
- [Rust std::fs::rename docs](https://doc.rust-lang.org/std/fs/fn.rename.html) — Windows uses MoveFileEx.
- [Microsoft Learn: MoveFileEx + MOVEFILE_REPLACE_EXISTING + NTFS + same volume = atomic](https://learn.microsoft.com/en-us/archive/msdn-technet-forums/449bb49d-8acc-48dc-a46f-0760ceddbfc3) — confirmed atomic for our use case.
- [rust-lang/rust PR#131072](https://github.com/rust-lang/rust/pull/131072) — Recent rust now uses POSIX rename semantics on Windows 1601+ via `FileRenameInfoEx`.

**Test coverage required:** Unit test that simulates double-write and asserts final content is correct. Not a race test (impossible to deterministically reproduce on a unit-test timeline), just a sequential equivalence check.

### Q7: DEPLOY.ps1 stub-injection idempotency

**Status:** GREEN with abort-gate design locked.

**Finding:** Per D-10 the design is "stub + commit gate": first `-Bump` appends the stub uncommitted; next `-Bump` aborts if the unchanged TODO marker is still present. This is the correct semantics. The DEPLOY.ps1 existing patterns at lines 199-238 (refuse-on-dirty-plugin.json and refuse-on-dirty-Cargo.toml) provide the precedent template.

**Mapping to DEPLOY.ps1 structure:**

Insertion site:
- **Bump abort gate:** Insert at lines 199-222, after the dirty-plugin.json / dirty-Cargo.toml refuse-on-dirty gates. New check: `if (CHANGELOG.md contains "TODO: changelog entry") { Write-Host "ERROR:..."; exit 1 }`. The scan is a substring or regex match for the literal `TODO: changelog entry`.
- **Stub injection:** Insert at lines 277-287, after Cargo.toml/plugin.json regex-rewrites (line 274) but BEFORE the `git add` + `git commit` (lines 279-282). The new stub MUST be appended to repo-root `CHANGELOG.md` AFTER the bump verification (lines 257-274) succeeds. Critically: `git add` MUST NOT stage the CHANGELOG.md — the stub stays in working-tree only. The commit at line 281 should explicitly list `plugin.json` and `Cargo.toml` (already does — line 279) and NOT add CHANGELOG.md to the path list.

**Idempotency property:** A user running `-Bump` twice without filling the CHANGELOG entry:
1. First run: bumps Cargo.toml/plugin.json, commits, appends stub to CHANGELOG.md (unstaged, uncommitted).
2. Second run: hits the abort gate at the dirty-state check OR the new TODO-marker check — depending on whether the user committed Cargo.toml/plugin.json from run 1 (they did; we commit it ourselves), so the dirty-state check passes; the TODO-marker check then fires and aborts.

**Wait — there's a gap:** Between run 1 and run 2, the user may have edited CHANGELOG.md AND committed it. If they committed it without filling in real prose, the TODO marker would still be present. Our check would still fire (correct). If they committed it WITH real prose, the TODO marker is gone, the check passes, and the next `-Bump` proceeds (correct). The gate works in all branches.

**Edge case:** If the user manually deletes the stub WITHOUT committing real content, the next `-Bump` sees no TODO marker but ALSO no entry for `NewVersion`. Should we check for both? Recommendation: also check that an H2 line matching `## [<NewVersion>] - YYYY-MM-DD` exists in CHANGELOG.md. If neither the stub nor a real entry for the bumped version is present, abort. This catches the "user deleted the stub but forgot to write a real entry" case.

**Recommended check shape** (insert at ~line 215):
```powershell
# Check that the previous bump's CHANGELOG entry was filled in.
# A "TODO: changelog entry" marker means the previous bump's stub is still pending.
$ChangelogPath = Join-Path $RepoRoot 'CHANGELOG.md'
if (Test-Path $ChangelogPath) {
    $ChangelogContent = Get-Content $ChangelogPath -Raw
    if ($ChangelogContent -match 'TODO: changelog entry') {
        Write-Host "ERROR: CHANGELOG.md contains 'TODO: changelog entry' from a prior -Bump." -ForegroundColor Red
        Write-Host "       Fill in the entry and commit before re-running -Bump." -ForegroundColor Red
        exit 1
    }
}
```

### Q8: Plan boundary recommendation

**Status:** RECOMMEND 3 plans, with refined boundary below.

**Recommendation:**

**Plan 01 — Doc Amendment + CHANGELOG.md Genesis + DEPLOY.ps1 (NO Rust)**
- Step 1 (FIRST COMMIT): REQUIREMENTS.md + ROADMAP.md amendment per D-12 (mirrors Phase 31 / Phase 33 doc-amendment-first pattern).
- Step 2: Author `CHANGELOG.md` at repo root by hand from `git log --grep='chore: bump'` skeleton (38 versions surfaced; see Appendix A below). Single commit with curated prose per version.
- Step 3: Extend `docs/DEPLOY.ps1`:
  - Sync step: copy repo-root `CHANGELOG.md` to `{Marketplace}/plugin/spt/CHANGELOG.md` alongside the existing binary/skills/hooks/plugin.json sync (~line 368, new `Invoke-Mutation` block).
  - `-Bump` flow: insert TODO-marker abort gate at ~line 215 (after dirty-state checks); insert stub append at ~line 287 (after Cargo.toml regex-rewrite verification, before `git add`). Critically: the stub append MUST NOT stage CHANGELOG.md.
- Verification: dry-run `-Bump patch` on a test branch; confirm stub appended, not committed; confirm `-Bump patch` again aborts with the new error.

**Plan 02 — Rust Hook + Sentinel + Parser + `version-remind` Subcommand (NO Claude-side block-body)**
- Author `src/owl/version_changelog.rs` (new module): sentinel read/write helpers, CHANGELOG.md parser, payload builder. Co-located unit tests for parser corpus (Q3 below).
- Wire into `src/owl/hook_idle.rs::run()`: after perch resolution (lines 25-32), after `OWL_ECHO_COMMUNE` guard (mirrored from spawn_echo_commune_if_live:64-69), BEFORE `set_idle_ready` (line 39). On version-mismatch: emit `decision:"block"` + `reason:"<spt-version-changelog>...</spt-version-changelog>"` via stdout JSON, then exit 0. Sentinel write happens at hook-entry top, BEFORE the block emission (per D-05).
- `stop_hook_active` guard: parse from stdin JSON; short-circuit if true.
- Add `version-remind <old>` subcommand to clap subcommand dispatch in `src/main.rs` (or wherever owl's bin entry is wired). ~10-line implementation: parse positional `<old>`, atomic_write_string `{"version":"<old>"}` to sentinel path, exit 0.
- Unit tests in `src/owl/version_changelog.rs::tests`: parser happy path, parser reject `[Unreleased]`, parser ignore link references, sentinel read missing → None, sentinel read present → Some, sentinel write atomic correctness.
- Hook-emission integration tests in `tests/version_changelog.rs` (NEW file): version-mismatch detection (single-step, multi-step, first-install silent, sentinel-equal-no-emit, OWL_ECHO_COMMUNE guard skip, no-perch guard skip, stop_hook_active guard skip); `version-remind` rollback round-trip.

**Plan 03 — Block Body Authoring + End-to-End Integration Test**
- Author the block-body text (the content inside `reason`'s `<spt-version-changelog>` XML). Includes: the 4-option AUQ instructions verbatim, the highlights-extraction framing wording (D-08), the exact `$OWL version-remind <old>` invocation string with `{old}` slot, the pointer to `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` for `Yes, highlights only` and `Yes, full changelog` paths.
- This text lives in the Rust payload-builder helper as a `format!` template. Plan 03 owns the wording; Plan 02 owns the scaffold.
- End-to-end integration test in `tests/version_changelog.rs`: synthesized version transition (sentinel set to v1.10.9, CARGO_PKG_VERSION mock'd or env-var-overridden to v1.10.10), assert stdout JSON has `decision:"block"` and `reason` matches expected XML envelope (snapshot-style assertion via golden fixture in `tests/golden/`).
- UAT: manually run `cargo build --release`, deploy via DEPLOY.ps1, manually trigger a version mismatch, verify the AUQ surfaces in a real Claude Code session.

**Rationale for boundary:**
- Plan 01 is doc/script only — no Rust, no test framework dependency. Can ship independently.
- Plan 02 is Rust internals — testable in isolation. Subcommand pairs with the hook because both share the parser+sentinel module; splitting would force the subcommand to duplicate the path-helper.
- Plan 03 is the user-facing surface — wording iterations don't churn Plan 02's scaffold. Integration test lives here because it depends on the block-body text Plan 03 owns.

**Dependency graph:** Plan 01 → Plan 02 → Plan 03 (linear). Plan 02 cannot start until CHANGELOG.md exists (parser needs sample data for unit tests). Plan 03 cannot start until Plan 02's payload builder exists.

**Alternative considered: 4 plans.** Splitting CHANGELOG.md genesis from amendment + DEPLOY.ps1 (separate Plan 0/Plan 1). Rejected — genesis is a single commit of one file; it doesn't warrant a separate plan boundary.

### Q9: Test fixture for synthesized version transition

**Status:** RECOMMEND env-var override seam.

**Recommendation:** Introduce a test-only env var `SPT_OVERRIDE_PKG_VERSION` (cfg-gated under `#[cfg(test)]` OR runtime-read). When set and non-empty, the version-changelog module reads it INSTEAD of `env!("CARGO_PKG_VERSION")`. Otherwise falls through to `env!`.

**Why this approach:**
- **Least invasive:** No rebuild required per test case. A single binary can simulate any version transition.
- **Matches existing patterns:** `SPT_HOME` env override (owlery.rs:18-32) is the established seam pattern in this codebase. `SPT_OVERRIDE_PKG_VERSION` is symmetric.
- **No production risk:** Document explicitly as "test-only; never set in production". Optionally gate behind a runtime check that emits a stderr warning if set in non-test contexts.

**Alternatives rejected:**
- **Mock seam (trait + impl injection):** Overkill for a single `env!` call site. Rust mocking ergonomics are poor for compile-time macros.
- **Helper that writes sentinel to temp `$SPT_HOME`:** Already needed for sentinel-side tests, but doesn't solve the CARGO_PKG_VERSION side — the binary version is baked in at compile time.
- **Per-test custom binary build:** Slow, flaky, increases test-runtime cost dramatically.

**Implementation seam (in `version_changelog.rs`):**
```rust
fn current_version() -> &'static str {
    // Test seam: SPT_OVERRIDE_PKG_VERSION lets tests simulate any version
    // without rebuilding. Production code paths see env!("CARGO_PKG_VERSION").
    if let Ok(v) = std::env::var("SPT_OVERRIDE_PKG_VERSION") {
        if !v.is_empty() {
            // SAFETY: leak is bounded by test count; acceptable for test-only seam.
            return Box::leak(v.into_boxed_str());
        }
    }
    env!("CARGO_PKG_VERSION")
}
```

Note: the `Box::leak` is intentional for the test-only path; in practice tests setting this env var run a finite number of times per binary lifecycle. If the leak offends, the function can return `String` and accept the allocation cost on every call.

### Q10: Keep-a-Changelog 1.1.0 — `[Unreleased]` and format details

**Status:** GREEN with `[Unreleased]` omitted from our format.

**Findings:**

1. **`## [X.Y.Z] - YYYY-MM-DD` format is canonical.** Verified at [keepachangelog.com/en/1.1.0](https://keepachangelog.com/en/1.1.0/).
2. **`[Unreleased]` section is conventional but not strictly required.** The spec recommends it for projects with active development; we explicitly omit it (D-09 says we author from `git log` retroactively — no current "unreleased" state at genesis).
3. **Spec recommends categories `Added/Changed/Deprecated/Removed/Fixed/Security`.** Phase 34's parser doesn't care about H3 subsections — it only locates H2 version headers. The body content between H2 boundaries is preserved verbatim for the `Yes, full changelog` path. Our author MAY use H3 subsections in prose, MAY use plain bullet lists, MAY use prose paragraphs — parser is body-content-agnostic.
4. **Link references at file bottom (`[1.10.10]: https://...`)** are valid per spec but irrelevant for our use case (we don't render comparison links). Parser MUST ignore them by virtue of the `^## \[` anchor (link refs start with `[`, not `## [`).

**Parser implications:** The regex `^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$` rejects `[Unreleased]` (no version match) and ignores link references (no `## ` prefix). No additional spec compliance needed.

### Q11: Block payload — embed full excerpt vs Claude-reads-at-yes-time

**Status:** RECOMMENDATION: Claude-reads-at-yes-time.

**Rationale:**

| Approach | Block size | Hook complexity | Token cost | Multi-version impact |
|----------|-----------|-----------------|------------|---------------------|
| Embed full excerpt in block | Large (every visible session pays for all transition prose) | Rust hook parses + concatenates H2 sections | High on every transition, even if user picks Skip | Worst with multi-version skip (e.g. 10 versions = 10x prose) |
| Claude-reads-at-yes-time | Small (just `{old,new,old_date,new_date,step_count}` + invocation string) | Rust hook parses only H2 headers (cheap) | Low on Skip / Remind paths; cost paid only on Yes paths | Multi-version still cheap (one CHANGELOG.md read regardless of step count) |

Block payload stays compact. Claude reads `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` only when user picks `Yes, full changelog` or `Yes, highlights only`. Cost is bounded by user choice.

**Block-body XML shape (recommendation):**
```xml
<spt-version-changelog>
  <old>1.10.9</old>
  <new>1.10.10</new>
  <old_date>2026-05-15</old_date>
  <new_date>2026-05-16</new_date>
  <step_count>1</step_count>
  <changelog_path>$CLAUDE_PLUGIN_ROOT/CHANGELOG.md</changelog_path>
  <invocation_for_remind>$OWL version-remind 1.10.9</invocation_for_remind>
  <instructions>
    Render an AskUserQuestion with question: "SPT plugin updated v1.10.9 → v1.10.10. Print changelog?"
    Options (exactly 4):
    - "Yes, full changelog": read $CLAUDE_PLUGIN_ROOT/CHANGELOG.md and print every H2 section
      where 1.10.9 &lt; version &lt;= 1.10.10, verbatim markdown.
    - "Yes, highlights only": read $CLAUDE_PLUGIN_ROOT/CHANGELOG.md, extract 2-5 most impactful
      feature adds and fixes from the same range; prefer user-facing behavior changes;
      skip purely-internal refactors and test-only changes. Append: "See $CLAUDE_PLUGIN_ROOT/CHANGELOG.md for full detail."
    - "Remind me later": invoke Bash: `$OWL version-remind 1.10.9` — pass the literal old version.
    - "Skip": no action.
  </instructions>
</spt-version-changelog>
```

Child elements (not JSON-as-text) recommended. Rationale: Claude parses XML reliably; child elements are unambiguous; readable in transcripts; matches the existing Phase 33 `<spt-live-auto-pick>` pattern convention.

`step_count` surfaces in the question text when `> 1` per D-07: `SPT plugin updated v1.10.5 → v1.12.0 (12 versions). Print changelog?`

---

## CHANGELOG.md Parser — Test Corpus

| Test Case | Input | Expected |
|-----------|-------|----------|
| **Happy path: single-version H2** | `## [1.10.10] - 2026-05-16` | Match: version=1.10.10, date=2026-05-16 |
| **Happy path: pre-1.0** | `## [0.9.3] - 2025-12-01` | Match: version=0.9.3 |
| **Reject: Unreleased** | `## [Unreleased]` | No match |
| **Reject: no date** | `## [1.10.10]` | No match |
| **Reject: incomplete semver (no patch)** | `## [1.10] - 2026-05-16` | No match |
| **Reject: incomplete semver (no minor)** | `## [1] - 2026-05-16` | No match |
| **Reject: link reference line** | `[1.10.10]: https://github.com/org/repo/compare/v1.10.9...v1.10.10` | No match (no `## ` prefix) |
| **Reject: H1 with same body** | `# [1.10.10] - 2026-05-16` | No match (single `#`) |
| **Reject: H3 with same body** | `### [1.10.10] - 2026-05-16` | No match (three `#`) |
| **Trailing whitespace tolerance** | `## [1.10.10] - 2026-05-16  ` (2 trailing spaces) | Researcher recommends: **accept** (loose anchor: `^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*$`). Reject only if extra non-whitespace content. |
| **BOM tolerance** | `\u{feff}## [1.10.10] - 2026-05-16` | Match — parser strips BOM at file-read time |
| **Edge: extra text after date** | `## [1.10.10] - 2026-05-16 (yanked)` | Researcher recommends: **reject** (strict-trailing-text rejection keeps parser deterministic; if a release is yanked, author should rewrite the H2 line or use a separate convention) |
| **Edge: lowercase version** | `## [1.10.10] - 2026-05-16` | Match (case-sensitive comparison) |
| **Edge: pre-release suffix** | `## [1.10.10-rc1] - 2026-05-16` | No match (our format is plain semver; pre-release out of scope) |
| **Edge: empty H2** | `## ` | No match |
| **Whitespace inside brackets** | `## [ 1.10.10 ] - 2026-05-16` | No match (strict bracket-hugging) |
| **Date format variation** | `## [1.10.10] - 2026/05/16` | No match (ISO-8601 hyphen-only) |
| **Range query (parser-driver level)** | Parser asked for all H2 sections between 1.10.5 (exclusive) and 1.10.7 (inclusive). File contains 1.10.5, 1.10.6, 1.10.7, 1.10.8. | Return body content of 1.10.6 and 1.10.7 only, concatenated. |
| **Body content preservation** | H2 section body includes nested H3 headers, code fences, lists | Body returned verbatim (parser DOES NOT modify body content) |

**Recommended regex:**
```rust
const H2_RE: &str = r"^## \[(\d+)\.(\d+)\.(\d+)\] - (\d{4})-(\d{2})-(\d{2})\s*$";
```

Three capture groups for semver components plus three for date components. Multiline mode; case-sensitive. Strict ISO-8601 date format.

**Parser API (in `src/owl/version_changelog.rs`):**
```rust
pub struct ChangelogEntry {
    pub version: (u32, u32, u32),
    pub date: String, // "YYYY-MM-DD" verbatim
    pub body: String, // content between this H2 and next H2 / EOF, verbatim
    pub line_no: usize, // for error messages
}

pub fn parse_changelog(content: &str) -> Vec<ChangelogEntry>;
pub fn sections_between(entries: &[ChangelogEntry], old: (u32,u32,u32), new: (u32,u32,u32)) -> Vec<&ChangelogEntry>;
```

---

## Integration Test Corpus (tests/version_changelog.rs)

| Test | Setup | Assertion |
|------|-------|-----------|
| `first_install_silent_writes_sentinel` | Empty `$SPT_HOME`; bake `SPT_OVERRIDE_PKG_VERSION=1.10.10`; run hook | Sentinel written `{"version":"1.10.10"}`; stdout JSON contains no `decision:"block"` |
| `single_step_mismatch_emits_block` | Sentinel `{"version":"1.10.9"}`; `SPT_OVERRIDE_PKG_VERSION=1.10.10`; CHANGELOG.md has both versions | stdout JSON `decision:"block"`, `reason` contains `<spt-version-changelog>` with `<old>1.10.9</old>` and `<new>1.10.10</new>` and `<step_count>1</step_count>`; sentinel now `1.10.10` |
| `multi_step_mismatch_emits_block_with_step_count` | Sentinel `1.10.5`; PKG_VERSION `1.10.10`; CHANGELOG has 1.10.5-1.10.10 | `step_count` = 5 in payload; question text mentions "5 versions" |
| `sentinel_equal_no_emit` | Sentinel `1.10.10`; PKG_VERSION `1.10.10` | No `decision:"block"`; sentinel unchanged (no write at all — write only on mismatch path) |
| `owl_echo_commune_guard_skips_emission` | Mismatch present; `OWL_ECHO_COMMUNE=1` set | No `decision:"block"`; sentinel unchanged |
| `no_perch_skips_emission` | Mismatch present; NO perch found (perch resolution returns None) | No `decision:"block"`; sentinel unchanged (per D-02 predicate) |
| `stop_hook_active_skips_emission` | Mismatch present; stdin JSON has `stop_hook_active: true` | No `decision:"block"`; sentinel unchanged (defensive infinite-loop guard) |
| `version_remind_rollback_round_trip` | Set sentinel to 1.10.10; invoke `owl version-remind 1.10.9`; assert sentinel now 1.10.9 | Sentinel content `{"version":"1.10.9"}` |
| `version_remind_missing_arg_exits_1` | Invoke `owl version-remind` with no arg | exit code 1; stderr non-empty |
| `version_remind_atomic_write` | Invoke `owl version-remind` twice in succession | No `.tmp` sibling left behind |
| `changelog_parser_unit_corpus` | Feed parser the 18 test-corpus rows above | All pass per expected behavior |

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Atomic file write | Custom tmp+rename | `atomic_write_string` (owlery.rs:248) | Already battle-tested in 8+ call sites; NTFS+POSIX semantics encoded |
| Spt-home path resolution | New env helper | `spt_home()` (owlery.rs:18-32) | Handles SPT_HOME override + platform default + create-if-missing |
| Perch resolution | New session-id lookup | `find_perch_by_session` / `find_perch_by_parent_pid` (existing) | Same path proven across hook_idle.rs, hook_prompt.rs, hook_check.rs |
| Markdown parsing | Full markdown crate | Regex H2 matcher (~10 LOC) | We only need H2 headers; body content is preserved verbatim |
| Version-equality comparison | Semver crate | Tuple `(u32, u32, u32)` comparison | Plain semver only; no pre-release / build-metadata in our format |
| JSON sentinel | Hand-roll serializer | `serde_json::to_string` / `from_str` | Single field; round-trip trivial |
| Recursion guard | New env var | `OWL_ECHO_COMMUNE` (hook_idle.rs:64-69) | Reuse existing — same haiku-subprocess gate applies |
| AUQ rendering in Rust | Try to emit AUQ JSON | Claude renders AUQ via block-body instructions | Stop hook has no AUQ-emission channel; Claude is the only AUQ surface |

---

## Common Pitfalls

### Pitfall 1: Sentinel write before block emission must NOT fail silently in ways that hide the mismatch

**What goes wrong:** If `atomic_write_string` returns `Err` (e.g., disk full, perm denied) and the code swallows the error and proceeds to emit the block, the user sees the block, but the sentinel stays at the OLD value → next Stop fires again → infinite block loop (mitigated by `stop_hook_active` but still alarming).

**Why it happens:** Existing best-effort pattern in `write_last_commune_epoch` (owlery.rs:264) swallows errors.

**How to avoid:** Either (a) swallow the error AND skip emission if sentinel write fails (degraded UX: no notification this session; correct next session), OR (b) emit anyway AND rely on `stop_hook_active` to break the loop. Recommendation: (a) — fail silent is the established codebase idiom and matches the "best-effort sentinel" philosophy from Phase 18.8.

**Warning signs:** Tests should include a "sentinel write fails (mock IO error)" case → assert no block emitted.

### Pitfall 2: `$CLAUDE_PLUGIN_ROOT` unset in non-plugin invocations

**What goes wrong:** During tests or direct CLI invocation (`owl hook-idle`), `$CLAUDE_PLUGIN_ROOT` is not set. If the parser fails to find CHANGELOG.md, the block emission either crashes or emits a degraded payload.

**Why it happens:** Tests bypass the Claude Code plugin invocation path.

**How to avoid:** Block emission should ALSO require `$CLAUDE_PLUGIN_ROOT` to resolve to a real file. If the env var is unset OR the file is missing, treat as "no changelog available" → still emit the block but with a degraded `<changelog_path>NOT_RESOLVED</changelog_path>` AND skip the `step_count` / `old_date` / `new_date` fields. Claude's AUQ surface still works (with just version numbers, no dates). Better than crashing.

**Warning signs:** Tests should explicitly include the "CLAUDE_PLUGIN_ROOT unset" branch.

### Pitfall 3: Parser ignores body content with `## ` at column 0 inside fenced code blocks

**What goes wrong:** A CHANGELOG.md entry's body contains a markdown code block with `## [something]` inside (e.g., showing a doc example) → parser incorrectly matches it as an H2.

**Why it happens:** Regex matches line-by-line without code-fence awareness.

**How to avoid:** Either (a) accept this edge case as author responsibility (don't include `## [` at line start in code blocks), OR (b) track code-fence state across lines. Recommendation: (a) — Phase 34 CHANGELOG.md will be hand-authored prose; if we ever auto-generate, revisit. Document in code comment so future maintainers know.

### Pitfall 4: `stop_hook_active` semantics — we should respect it even though D-05 sentinel pre-write prevents normal re-fire

**What goes wrong:** Edge case where sentinel write fails (Pitfall 1) — without `stop_hook_active` guard, hook would re-fire infinitely if write keeps failing.

**Why it happens:** Sentinel write is fallible; `stop_hook_active` is Claude Code's documented infinite-loop guard.

**How to avoid:** Parse `stop_hook_active` from stdin JSON. If true, short-circuit — exit 0 with no block emission, regardless of version mismatch. Belt-and-suspenders with the sentinel pre-write.

### Pitfall 5: DEPLOY.ps1 stub append at WRONG point causes git add to stage CHANGELOG.md

**What goes wrong:** If stub append runs BEFORE `git add 'plugin/spt/.claude-plugin/plugin.json' 'Cargo.toml'` (DEPLOY.ps1 line 279), and a user later runs `git add -A` reflexively, CHANGELOG.md gets committed in a different commit and the unchanged-abort gate breaks (TODO marker now committed, not in working tree).

**Why it happens:** Author confusion about where in the `-Bump` sequence to insert the stub.

**How to avoid:** Stub append MUST happen AFTER `git commit` (line 281) — the bump commit excludes CHANGELOG.md. The stub then lives in working-tree only. Document this constraint with a code comment.

### Pitfall 6: Block payload's `step_count` is for SURFACE TEXT only — full-changelog body still reads ALL intermediate versions

**What goes wrong:** Claude sees `step_count=5` and prints only 5 version headers without reading the full content from `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md`.

**Why it happens:** Claude is being asked to surface a count, not a body.

**How to avoid:** Block-body instructions for `Yes, full changelog` MUST explicitly say "read $CLAUDE_PLUGIN_ROOT/CHANGELOG.md and print every H2 section where {old} < version ≤ {new}". The `step_count` is for the AUQ question text only, not the body-rendering surface.

---

## Runtime State Inventory

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | New sentinel `$SPT_HOME/last-seen-version.json` — single field `{"version":"X.Y.Z"}`. **Pre-existing data:** none (new artifact in Phase 34). | Code edit: create read/write helpers in `version_changelog.rs`. No data migration needed (first-install silent path handles missing sentinel per VERS-03). |
| Live service config | `$CLAUDE_PLUGIN_ROOT` resolves to `~/.claude/plugins/cache/cplugs/spt/<version>/` at runtime; CHANGELOG.md must be present at that path. | DEPLOY.ps1 sync step copies CHANGELOG.md alongside other plugin files (per D-11). Hook reads at invocation time. |
| OS-registered state | None — no Task Scheduler, pm2, systemd, or launchd registrations involve Phase 34 artifacts. | None. |
| Secrets/env vars | New test-only env var `SPT_OVERRIDE_PKG_VERSION` (test seam per Q9). `$OWL` env var dependency confirmed (per Q5). `$CLAUDE_PLUGIN_ROOT` confirmed (per Q4). | None — test seam is additive. |
| Build artifacts | `target/release/owl.exe` rebuilds with new `version_changelog.rs` module. Marketplace copy via DEPLOY.ps1 (existing pattern). | Standard `cargo build --release` + DEPLOY.ps1 sync. |

**The canonical question:** *After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?*

Phase 34 is greenfield (new artifact), not a rename. No legacy cached state needs migration. The only runtime-state interaction is the sentinel itself, which is created on first hook fire after deploy.

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Rust toolchain (cargo) | Plan 02, Plan 03 build | ✓ | (existing) | — |
| PowerShell 5.1+ | Plan 01 DEPLOY.ps1 changes | ✓ | (existing) | — |
| `git` CLI | Plan 01 CHANGELOG genesis (`git log --grep`) | ✓ | (existing) | — |
| `serde_json` crate | Sentinel JSON | ✓ | (existing in Cargo.toml) | — |
| `clap` crate | `version-remind` subcommand | ✓ | (existing in Cargo.toml) | — |
| Claude Code (`claude` CLI) | UAT verification of AUQ surface | ✓ | (existing) | Manual test via direct hook invocation possible but lower-fidelity |

No missing dependencies. No blocking gaps.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Rust `cargo test` (built-in, no extra crate) |
| Config file | `Cargo.toml` (existing) |
| Quick run command | `cargo test --lib version_changelog` |
| Full suite command | `cargo test` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| VERS-01 | Sentinel at `$SPT_HOME/last-seen-version.json` | unit | `cargo test sentinel_path` | ❌ Wave 2 (new module) |
| VERS-02 | Compile-time CARGO_PKG_VERSION read; never reads plugin.json at runtime | unit + crate-wide audit | `cargo test current_version_uses_env_macro` + grep audit | ❌ Wave 2 |
| VERS-03 | First-install silent write | integration | `cargo test --test version_changelog first_install_silent_writes_sentinel` | ❌ Wave 2 (new integration file) |
| VERS-04 | 4-option AUQ shape (per D-12 amendment) | integration | `cargo test --test version_changelog single_step_mismatch_emits_block` | ❌ Wave 2 |
| VERS-05 | Atomic write via `atomic_write_string` | unit | `cargo test sentinel_write_atomic` | ❌ Wave 2 |
| VERS-06 | Multi-version uses same 4-option (per D-12) | integration | `cargo test --test version_changelog multi_step_mismatch_emits_block_with_step_count` | ❌ Wave 2 |
| VERS-07 | DEPLOY.ps1 syncs CHANGELOG.md to plugin meta dir | manual (DEPLOY.ps1 dry-run) | `powershell -ExecutionPolicy Bypass -File docs/DEPLOY.ps1 -DryRun` | n/a — script |
| VERS-08 | AUTO-03 + VERS-04 coexistence by hook separation | integration | manual UAT — both hooks present in fixture, neither collides | n/a — UAT |
| VERS-09 | Single write site at hook-entry top | manual code review + grep audit | `grep -r "last-seen-version" src/` should yield 2 hits (hook + subcommand) | n/a — review |

### Sampling Rate
- **Per task commit:** `cargo test --lib version_changelog` (parser unit tests; fast)
- **Per wave merge:** `cargo test` (full suite including integration)
- **Phase gate:** Full suite green + UAT against real Claude Code session

### Wave 0 Gaps
- `src/owl/version_changelog.rs` — new module file with parser + sentinel helpers
- `tests/version_changelog.rs` — new integration test file (mirrors `tests/skill_hints.rs` from Phase 32)
- `tests/golden/version_changelog_block.txt` — golden fixture for block-body snapshot assertion (Plan 03)

---

## Security Domain

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | n/a — local-only sentinel; no auth boundary |
| V3 Session Management | no | n/a |
| V4 Access Control | no | n/a — sentinel lives under user's own `$SPT_HOME` |
| V5 Input Validation | yes | Parse `<old version>` arg in `version-remind`: validate against semver regex BEFORE writing sentinel. If not matching `^\d+\.\d+\.\d+$`, exit 1. Prevents arbitrary content injection into the sentinel JSON. |
| V6 Cryptography | no | n/a — no secrets, no signing |

### Known Threat Patterns

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| `version-remind` arg injection | Tampering | Validate arg matches `^\d+\.\d+\.\d+$` regex; reject any other input |
| Sentinel path traversal | Tampering | `$SPT_HOME` resolution is authoritative; path is constructed via `spt_home().join("last-seen-version.json")` — no user input in the path |
| CHANGELOG.md content injection | Tampering | Author-controlled file; content goes into block `reason` text → reaches Claude, but Claude is the only consumer and the format is markdown. No XSS / code-exec surface. |
| `OWL_ECHO_COMMUNE` recursion guard bypass | DoS (loop) | Guard MUST come BEFORE version-changelog logic (D-03); mirror hook_idle.rs:64-69 exactly |

**Argument validation MUST happen in the `version-remind` subcommand entry, BEFORE any filesystem write.**

---

## Code Examples (Verified Patterns)

### Atomic write of sentinel (Plan 02)

```rust
// Source: src/common/owlery.rs:248 (existing helper)
use crate::common::owlery::{atomic_write_string, spt_home};

fn write_sentinel(version: &str) -> std::io::Result<()> {
    let path = spt_home().join("last-seen-version.json");
    let body = format!("{{\"version\":\"{}\"}}", version);
    atomic_write_string(&path, &body)
}
```

### Sentinel read with first-install handling (Plan 02)

```rust
// First-install: read returns None; caller writes current version silently.
fn read_sentinel() -> Option<String> {
    let path = spt_home().join("last-seen-version.json");
    let content = std::fs::read_to_string(&path).ok()?;
    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
    v.get("version")?.as_str().map(String::from)
}
```

### Hook emission via stdout JSON (Plan 02)

```rust
// Source: existing pattern in src/common/hook_output.rs
fn emit_block(reason: &str) {
    let output = serde_json::json!({
        "decision": "block",
        "reason": reason,
    });
    println!("{}", output);
}
```

### Stop hook stdin parsing (Plan 02 — extend existing parse_hook_stdin)

```rust
// Stop hook input includes stop_hook_active per ecosystem convention
#[derive(Deserialize)]
struct StopHookInput {
    #[serde(default)]
    session_id: Option<String>,
    #[serde(default)]
    stop_hook_active: bool,
}
```

### `version-remind` subcommand body (Plan 02)

```rust
// New subcommand. Bare positional arg per D-08.
pub fn version_remind(old: &str) -> std::process::ExitCode {
    // V5 input validation: reject non-semver
    let semver_re = regex::Regex::new(r"^\d+\.\d+\.\d+$").unwrap();
    if !semver_re.is_match(old) {
        eprintln!("error: <old version> must match X.Y.Z semver");
        return std::process::ExitCode::from(1);
    }
    let path = spt_home().join("last-seen-version.json");
    let body = format!("{{\"version\":\"{}\"}}", old);
    match atomic_write_string(&path, &body) {
        Ok(_) => std::process::ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: sentinel write failed: {}", e);
            std::process::ExitCode::from(1)
        }
    }
}
```

Note: `regex` crate may not be in Cargo.toml today. If not, use a hand-rolled validator (`old.split('.').count() == 3 && all parts parse as u32`).

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Stop hook `reason` text reaches Claude as continuation context strong enough for Claude to invoke AUQ in same turn | Q1 / Q2 | If wrong: AUQ never fires; user never sees changelog prompt. Mitigation: cross-source confirmation (4+ sources agree); ecosystem-wide "stop hook task completion" pattern relies on this exactly. Confidence: HIGH. |
| A2 | `regex` crate not yet in Cargo.toml | Q9 / code example | If wrong: minor — just use it. Researcher did not verify Cargo.toml deps. Planner should check before Plan 02 implementation. |
| A3 | `step_count` calculation: count of H2 sections where `old < version <= new` (exclusive of old, inclusive of new) | Q11 / Q3 corpus | If wrong: off-by-one in question text. Locked in CONTEXT.md D-07; aligns with Discussion Log Round 2. Confidence: HIGH (locked in CONTEXT). |
| A4 | Test seam env var `SPT_OVERRIDE_PKG_VERSION` is acceptable codebase pattern | Q9 | If user/maintainer rejects test-only env var approach, fall back to a `cfg(test)` module replacement (slightly more invasive). Confidence: MEDIUM — pattern matches `SPT_HOME` precedent but is novel for VERSION specifically. |
| A5 | DEPLOY.ps1 `-Bump` insertion sites at lines 215 (abort gate) and 287 (stub append) | Q7 | If wrong: stub appended at wrong sequence point may either be staged into the bump commit (bad) or fail to detect dirty state (bad). Mitigation: Plan 01 must dry-run DEPLOY.ps1 and verify the sequence holds. Confidence: MEDIUM — based on reading the script, not on test execution. |
| A6 | `$OWL` env var persists across Claude Code turns via `~/.claude/settings.json` injection | Q5 | If wrong: Claude's `Bash($OWL version-remind 1.10.9)` invocation would fail with "$OWL: command not found". Mitigation: every existing `/spt:*` skill uses `$OWL` and works — strong empirical evidence. Confidence: HIGH. |

---

## Open Questions

1. **Should the integration test golden-fixture include a multi-version scenario?**
   - What we know: single-step and multi-step are both required test cases (Q3, integration corpus).
   - What's unclear: how many H2 sections to include in the test fixture CHANGELOG.md, and whether to snapshot the full `reason` string or just key fields.
   - Recommendation: Snapshot just `<old>`, `<new>`, `<step_count>` and the invocation string. Body content varies with CHANGELOG.md edits and would make tests fragile.

2. **Should `version-remind` log its action to stderr for debugging?**
   - What we know: success exit 0, failure exit 1.
   - What's unclear: whether a single-line stderr "rolled back sentinel to 1.10.9" is helpful or noise.
   - Recommendation: emit a single line on success (matches `READY:id` / `SENT:id` status-tag convention from CLAUDE.md §Conventions). E.g., `REMINDED:1.10.9`. Final wording in Plan 02.

3. **Should the block body include the dates in the AUQ question text, or only in the AUQ description / pre-question framing?**
   - What we know: D-07 says dates surface in "AUQ description / Claude's pre-question framing, not in the question line itself".
   - What's unclear: AskUserQuestion's API may or may not have a description slot per option vs a global header.
   - Recommendation: Claude renders dates in the prose surface BEFORE invoking AUQ (e.g., "v1.10.9 (released 2026-05-15) → v1.10.10 (released 2026-05-16). Print changelog?"). AUQ itself uses just the version-number question.

---

## State of the Art

| Old Approach (in REQUIREMENTS.md as written) | Current Approach (per CONTEXT.md D-01..D-12) | When Changed | Impact |
|--------------|------------------|--------------|--------|
| SessionStart hook emission | Stop hook emission | Phase 34 discuss-phase | Pivots VERS-02 from `plugin_session_start.rs` to `hook_idle.rs`; user is between turns when prompted, not at session boot |
| 3-option AUQ (yes/no/dismiss) | 4-option AUQ (yes-full / yes-highlights / remind / skip) | Phase 34 discuss-phase | VERS-04 amendment; richer UX, splits "yes" into "all detail" vs "discretion summary" |
| Multi-version = version-list + pointer (never full prose) | Multi-version = same 4-option as single; `Yes, full` renders all interim H2 sections verbatim | Phase 34 discuss-phase | VERS-06 amendment; user-driven choice rather than format-driven constraint |
| Sentinel write coupled to user response | Sentinel pre-written at hook entry; `version-remind` rolls back on `Remind me later` | Phase 34 discuss-phase Round 5 | Inverts ack/dismiss flow — most-used paths (Yes-full / Yes-highlights / Skip) have zero round-trip; less-common Remind pays the cost |
| `last-seen-version.json` inside `owlery/` | `last-seen-version.json` SIBLING to `owlery/` | VERS-01 (original requirement) | Per VERS-01 wording; respected in D-05 |

**No deprecated items.** Phase 34 is greenfield.

---

## Plan-Boundary Recommendation Summary

**3 plans** in linear sequence:

| Plan | Title | Touches | Tests |
|------|-------|---------|-------|
| Plan 01 | Doc amendment + CHANGELOG.md genesis + DEPLOY.ps1 stub/sync | REQUIREMENTS.md, ROADMAP.md, CHANGELOG.md (new), docs/DEPLOY.ps1 | DEPLOY.ps1 dry-run; manual review |
| Plan 02 | Rust hook + sentinel + parser + `version-remind` subcommand | src/owl/version_changelog.rs (new), src/owl/hook_idle.rs, src/main.rs (subcommand wiring), tests/version_changelog.rs | Unit tests for parser corpus; integration tests for hook emission |
| Plan 03 | Block body authoring + end-to-end integration test + UAT | src/owl/version_changelog.rs (block-body template), tests/version_changelog.rs (snapshot), tests/golden/ | Golden snapshot; manual UAT against real Claude Code |

**Dependencies:** 01 → 02 → 03 (linear). Plan 02 needs CHANGELOG.md to exist (for parser tests against real content). Plan 03 needs Plan 02's payload-builder helper.

---

## Open Risks / Blockers for Planner

1. **(LOW)** DEPLOY.ps1 stub-append site exact insertion may need adjustment after Plan 01 dry-run. Recommend: dry-run before committing the script change.
2. **(LOW)** `regex` crate availability — verify before Plan 02 implementation. Easy fix if missing.
3. **(LOW)** Yanked-version handling deferred (parser rejects `## [1.10.10] - 2026-05-16 (yanked)`). If a real yank ever happens, author edits CHANGELOG.md to remove the H2 line entirely. Document as a constraint in CHANGELOG.md authoring guidelines.
4. **(MEDIUM)** AUQ-instructions wording in block body (Plan 03) needs careful authoring to ensure Claude reliably invokes the right tools in the right order. Recommend: explicit numbered steps inside `<instructions>` element.
5. **(LOW)** The block-body XML shape (Q11 recommendation) is researcher's pick — planner is empowered to revise. Constraint: must carry `old`, `new`, `old_date`, `new_date`, `step_count`, and the exact `$OWL version-remind <old>` invocation string verbatim.

---

## Appendix A: CHANGELOG.md Genesis Scope (Plan 01 Step 2)

Versions identified from `git log --grep='chore: bump'` (38 entries):

```
v1.10.10  (most recent — Phase 33 completion era)
v1.10.9
v1.10.8
v1.10.7
v1.10.6
v1.10.5
v1.10.4
v1.10.3
v1.10.2
v1.10.1
v1.10.0
v1.9.15
v1.9.14
v1.9.13
v1.9.12
v1.9.10  (note: 1.9.11 missing — skip in CHANGELOG)
v1.9.9
v1.9.8
v1.9.7
v1.9.5   (note: 1.9.6 missing — skip)
v1.9.2   (note: 1.9.0/1/3/4 missing — skip)
v1.8.11
v1.8.10
v1.8.9
v1.8.8
v1.8.7
v1.8.6
v1.8.5
v1.8.4
v1.8.3
v1.8.2
v1.8.1
v1.8.0
v1.7.5
v1.7.4
v1.7.3
v1.7.2
v1.5.4   (older format: "bump plugin version to 1.5.4 and add test artifacts")
```

**Author note for Plan 01:** Per D-09, author curated prose per version using `git log <range>` to scope the changes between each bump. The CHANGELOG.md is NOT a literal log dump — it is a human-readable summary. Skipped versions (1.9.11, 1.9.6, etc.) need no entry. Format: Keep-a-Changelog 1.1.0; H2 `## [X.Y.Z] - YYYY-MM-DD`; body uses bullet lists or prose paragraphs (author's discretion — parser doesn't care about body structure).

---

## Sources

### Primary (HIGH confidence)
- `~/.claude/reference_docs/claude-code-hooks.md` — Stop hook schema, environment variables, exit codes
- `src/owl/hook_idle.rs` — Stop hook emission site, OWL_ECHO_COMMUNE guard pattern
- `src/common/owlery.rs:18-32, 65, 248` — spt_home, owlery_dir, atomic_write_string
- `docs/DEPLOY.ps1:154-292` — `-Bump` flow, sync step
- `.planning/phases/34-version-change-changelog/34-CONTEXT.md` — 12 locked decisions (D-01..D-12)
- `.planning/phases/34-version-change-changelog/34-DISCUSSION-LOG.md` — alternatives + rationale
- `.planning/REQUIREMENTS.md` — VERS-01..VERS-09 + Out-of-Scope clauses

### Secondary (MEDIUM-HIGH confidence — multi-source verified)
- [Anthropic Claude Code Hooks reference](https://code.claude.com/docs/en/hooks) — Stop hook output schema, environment variables
- [disler/claude-code-hooks-mastery](https://github.com/disler/claude-code-hooks-mastery) — Stop hook `decision:"block"` semantics, ecosystem patterns
- [Anthropic blog: How to configure hooks](https://claude.com/blog/how-to-configure-hooks) — Stop hook reason propagation
- [Claudefa.st Stop Hook task enforcement](https://claudefa.st/blog/tools/hooks/stop-hook-task-enforcement) — reason field "fed back to Claude"
- [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) — format specification

### Tertiary (LOW confidence — single-source / training data)
- Rust `std::fs::rename` Windows behavior — verified via [doc.rust-lang.org](https://doc.rust-lang.org/std/fs/fn.rename.html) and [Microsoft Learn](https://learn.microsoft.com/en-us/archive/msdn-technet-forums/449bb49d-8acc-48dc-a46f-0760ceddbfc3); HIGH confidence on same-volume NTFS atomicity, LOWER on concurrent-write edge cases

---

## Metadata

**Confidence breakdown:**
- Stop hook `decision:"block"` semantics (Q1/Q2): HIGH (4+ sources agree; ecosystem-wide pattern; CONTEXT.md design is sound)
- Standard stack: HIGH (no new deps; all existing crates)
- Architecture (3-plan boundary): HIGH (mirrors Phase 31/32/33 amendment-first pattern)
- Parser corpus: HIGH (test cases derived from Keep-a-Changelog spec + common edge cases)
- DEPLOY.ps1 stub injection sites: MEDIUM (based on script reading, not dry-run execution)
- Test seam (SPT_OVERRIDE_PKG_VERSION): MEDIUM (novel pattern; need user confirm before Plan 02)
- Pitfalls: HIGH (derived from established codebase patterns: best-effort IO, recursion guard, atomic write)

**Research date:** 2026-05-17
**Valid until:** 2026-06-16 (30 days — Claude Code hooks API is stable-ish but new event types added periodically)
