# Phase 34: Version-Change Changelog - Pattern Map

**Mapped:** 2026-05-17
**Files analyzed:** 8 (4 new, 4 modified)
**Analogs found:** 8 / 8

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| NEW `src/owl/version_changelog.rs` | utility (sentinel I/O + parser + payload builder) | file-I/O + transform | `src/common/owlery.rs:248-255` (atomic_write_string) + `src/owl/hook_idle.rs:71-98` (sentinel read pattern) | exact (composite) |
| NEW `owl version-remind <old>` subcommand | controller (one-shot CLI handler) | request-response (argv → file write) | `src/cli.rs:142-153` + `src/owl/mod.rs:116-130` (`NewAlarm`) | exact |
| NEW repo-root `CHANGELOG.md` | config (static asset) | n/a | `psyche.md` (repo root) + no `include_str!` (D-11) | placement-only analog |
| NEW `tests/version_changelog.rs` | test (integration) | event-driven (subprocess + stdin) | `tests/auto_pick_integration.rs:1-120` (SPT_HOME sandbox + assert_cmd) | exact |
| MOD `src/owl/hook_idle.rs` | controller (Stop hook) | event-driven (hook stdin → JSON stdout) | self — append at run() top mirroring own `OWL_ECHO_COMMUNE` guard (lines 64-69) | self-reference |
| MOD `docs/DEPLOY.ps1` | utility (build script) | batch (file rewrite + verify) | `docs/DEPLOY.ps1:246-274` (regex-replace-then-verify) + `:359-370` (Copy-Item to marketplace) | exact |
| MOD `.planning/REQUIREMENTS.md` | config (spec doc) | n/a | Phase 31 D-11 / Phase 33 D-03/D-04 amendment commit | structural |
| MOD `.planning/ROADMAP.md` | config (spec doc) | n/a | Phase 31 D-11 / Phase 33 D-03/D-04 amendment commit | structural |

---

## Pattern Assignments

### NEW `src/owl/version_changelog.rs` (utility, file-I/O + transform)

**Analog A — sentinel atomic write** (`src/common/owlery.rs:248-255`):
```rust
pub fn atomic_write_string(path: &std::path::Path, body: &str) -> std::io::Result<()> {
    let tmp = path.with_file_name(format!(
        "{}.tmp",
        path.file_name().and_then(|s| s.to_str()).unwrap_or("epoch")
    ));
    fs::write(&tmp, body)?;
    fs::rename(&tmp, path)
}
```
**Reuse verbatim.** Sentinel path: `owlery::spt_home().join("last-seen-version.json")`. Body: `serde_json::to_string(&SentinelV1 { version: new })?`.

**Analog B — sentinel read with graceful degrade** (`src/owl/hook_idle.rs:71-83`):
```rust
let info_path = owlery::info_file(owl_id);
let content = match std::fs::read_to_string(&info_path) {
    Ok(c) => c,
    Err(_) => return,
};
let info: types::InfoJson = match serde_json::from_str(&content) {
    Ok(i) => i,
    Err(_) => return,
};
```
**Copy shape.** For sentinel: missing-file (first-install) → silently write NEW + skip emission; malformed JSON → log nothing, treat as missing.

**Analog C — compile-time version source** (`src/owl/poll.rs:75`, `setup.rs:35`, `live/stop.rs:199`):
```rust
env!("CARGO_PKG_VERSION")
```
**Use identically.** Stored result for sentinel comparison; NEVER read runtime `plugin.json`.

**Sentinel struct (locked per CONTEXT specifics):**
```rust
#[derive(serde::Serialize, serde::Deserialize)]
struct SentinelV1 { version: String }
```

**CHANGELOG parser regex (locked):** `^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$` — case-sensitive, rejects `[Unreleased]`, ignores link-reference lines (`[1.2.3]: <url>` form). No external `regex` crate dep — use the `regex` crate already in `Cargo.toml` if present; otherwise hand-roll (mirror `tests/skill_hints.rs:29-56` zero-dep YAML parser style).

**Unit-test pattern** (co-located `#[cfg(test)]`, mirror `src/owl/hook_idle.rs:100-264`):
- `ENV_LOCK: Mutex<()>` static + `EnvSnapshot` RAII for `SPT_HOME` mutation (lines 108-135)
- `setup_spt_home(&tmp)` helper (lines 162-165)
- Per-test tempdir via `tempfile::tempdir()`

---

### NEW `owl version-remind <old>` subcommand (controller, request-response)

**Clap registration analog** (`src/cli.rs:142-153` — `NewAlarm`, the most recent positional-arg subcommand):
```rust
/// Schedule a one-shot timed reminder ...
#[command(name = "new-alarm")]
NewAlarm {
    self_id: String,
    time_spec: String,
    ...
},
```

**Pattern to copy for Phase 34** — add to `src/cli.rs` `Commands` enum (no `hide = true`; visible like `NewAlarm`):
```rust
/// Roll the last-seen version sentinel back to <old> so the next Stop
/// hook re-fires the version-change prompt. Invoked by Claude when the
/// user picks `Remind me later` from the AUQ.
#[command(name = "version-remind")]
VersionRemind {
    /// The previous version to roll the sentinel back to (e.g. `1.10.9`).
    /// MUST match the `old` value from the <spt-version-changelog> block
    /// Claude received — the subcommand cannot infer this otherwise.
    old: String,
},
```

**Dispatch arm** (`src/owl/mod.rs:37-160` `handle_command` match — add alongside `Commands::EchoCommune` at ~line 153):
```rust
Some(Commands::VersionRemind { old }) => {
    version_changelog::run_version_remind(&old);
}
```

**Handler body** (~10 lines, mirrors `src/common/owlery.rs:264-269` `write_last_commune_epoch`):
```rust
pub fn run_version_remind(old: &str) {
    let path = owlery::spt_home().join("last-seen-version.json");
    let body = serde_json::to_string(&SentinelV1 { version: old.to_string() })
        .expect("SentinelV1 serialization is infallible");
    match owlery::atomic_write_string(&path, &body) {
        Ok(_) => std::process::exit(0),
        Err(e) => {
            eprintln!("[owl] version-remind: failed to rewrite {}: {}", path.display(), e);
            std::process::exit(1);
        }
    }
}
```

**Clap parse-test pattern** (`src/cli.rs:351-411` — co-locate in `#[cfg(test)] mod tests`):
```rust
#[test]
fn parses_version_remind_positional() {
    let cli = Cli::try_parse_from(["owl", "version-remind", "1.10.9"]).unwrap();
    match cli.command {
        Some(Commands::VersionRemind { old }) => assert_eq!(old, "1.10.9"),
        other => panic!("expected VersionRemind, got {:?}", other),
    }
}
```

---

### NEW repo-root `CHANGELOG.md` (config, static asset)

**Placement analog:** `psyche.md` (repo root, copied into plugin meta dir). Phase 34 explicitly diverges from `psyche.md` in ONE respect: NOT embedded via `include_str!` (D-11 — runtime-read from `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md`).

**Format (locked, Keep-a-Changelog 1.1.0):**
```markdown
# Changelog

## [1.11.0] - 2026-05-17

- Added: ...
- Fixed: ...

## [1.10.10] - 2026-05-13

- ...
```

**Genesis** (Plan 01 step 1, after REQUIREMENTS/ROADMAP amendment commit): author by hand using `git log --oneline --grep='chore: bump'` as the version skeleton. One commit. No auto-regeneration.

---

### NEW `tests/version_changelog.rs` (test, event-driven integration)

**Analog:** `tests/auto_pick_integration.rs:1-120` — the closest existing subprocess-driven hook test.

**Imports + harness pattern** (lines 42-99 verbatim):
```rust
use std::fs;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());

struct SptHomeGuard { prior: Option<String> }
impl SptHomeGuard {
    fn set(path: &std::path::Path) -> Self {
        let prior = std::env::var("SPT_HOME").ok();
        std::env::set_var("SPT_HOME", path);
        Self { prior }
    }
}
impl Drop for SptHomeGuard {
    fn drop(&mut self) {
        match &self.prior {
            Some(v) => std::env::set_var("SPT_HOME", v),
            None => std::env::remove_var("SPT_HOME"),
        }
    }
}
```

**Hook invocation pattern** (lines 81-99 — `assert_cmd::Command::cargo_bin("owl")`):
```rust
let mut cmd = assert_cmd::Command::cargo_bin("owl").expect("owl binary must be built");
cmd.arg("hook-idle")
    .env("SPT_HOME", tmp)
    .env("HOME", tmp)
    .env("USERPROFILE", tmp)
    .env_remove("OWL_ECHO_COMMUNE");
// stdin pipe via .write_stdin(json) per assert_cmd
```

**Perch-fixture pattern** (lines 105-120 verbatim — `write_live_perch`):
- create `{spt_home}/owlery/{id}/ready`
- write `info.json` with `parent_pid = std::process::id() as u64`
- so `find_perch_by_parent_pid` resolves to this perch when the subprocess inherits the test PID as its ppid

**Test cases to cover (per CONTEXT Claude's Discretion → Tests bullet):**
1. version-mismatch single-step → emits block
2. version-mismatch multi-step → emits block with `step_count > 1`
3. first-install (no sentinel) → silent write, no emit
4. sentinel == compile-time version → no emit
5. `OWL_ECHO_COMMUNE=1` → no emit (recursion guard)
6. no perch resolved → no emit
7. `version-remind 1.10.9` → sentinel rewritten round-trip
8. CHANGELOG.md parser: happy path, `[Unreleased]` reject, malformed H2 graceful, link-reference lines ignored

---

### MOD `src/owl/hook_idle.rs` (controller, event-driven)

**This is a self-reference — append a new block at the TOP of `run()` between perch resolution (line 32) and `set_idle_ready` (line 39).**

**Recursion guard (mirror lines 64-69 EXACTLY — MUST be first):**
```rust
if std::env::var("OWL_ECHO_COMMUNE")
    .map(|v| !v.is_empty())
    .unwrap_or(false)
{
    // skip version-change check in haiku echo-commune subprocess
} else {
    // version-change emission block here
}
```
**OR** reorder so OWL_ECHO_COMMUNE guard runs at the very top of `run()` itself (cleaner — the existing guard at line 64-69 then becomes a no-op or is removed since `spawn_echo_commune_if_live` is called after). Planner's choice; CONTEXT D-03 accepts either.

**Block emission (D-04 — Stop hook output schema):**
```rust
// Stop hooks have no hookSpecificOutput.additionalContext channel
// (per the hook_idle.rs:1-5 file-doc comment). Phase 34 uses
// decision:"block" + reason instead. Research-confirmed: reason
// text propagates to Claude as re-engagement context.
let response = serde_json::json!({
    "decision": "block",
    "reason": format!(
        "<spt-version-changelog>\n{}\n</spt-version-changelog>",
        payload_xml
    ),
});
println!("{}", serde_json::to_string(&response).unwrap());
```
**NOTE:** This is the channel Phase 34 picks — NOT the Pattern S2 `hookSpecificOutput` envelope from `plugin_session_start.rs:139-155` (`emit_auto_pick`). The S2 pattern is for SessionStart/PreToolUse/UserPromptSubmit hooks. Stop's only context-injection channel is `decision:"block"` + `reason`.

**Sentinel write site (D-05):**
```rust
// Write the NEW version BEFORE emitting the block. The `Remind me later`
// AUQ option calls `$OWL version-remind <old>` to roll back; all other
// branches (Yes-full / Yes-highlights / Skip) leave the sentinel at NEW.
let _ = owlery::atomic_write_string(
    &owlery::spt_home().join("last-seen-version.json"),
    &serde_json::to_string(&SentinelV1 { version: current.to_string() }).unwrap(),
);
```

**SessionStart's `emit_auto_pick` envelope for comparison** (`plugin_session_start.rs:139-155`):
```rust
pub(crate) fn emit_auto_pick(pick_spec_json: &str) {
    let context = format!(
        "<spt-live-auto-pick>\n{}\n</spt-live-auto-pick>",
        pick_spec_json
    );
    let response = serde_json::json!({
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": context,
        }
    });
    let out = serde_json::to_string(&response).unwrap_or_else(|_| {
        r#"{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":""}}"#.to_string()
    });
    println!("{}", out);
}
```
**Phase 34 does NOT mirror this shape.** Stop uses `decision:"block"` per the file-doc comment at `hook_idle.rs:1-5`. Confirm semantics during research per CONTEXT D-04 / Open Question.

---

### MOD `docs/DEPLOY.ps1` (utility, batch file rewrite)

**Analog A — stub-injection (regex-replace-then-verify)** at `docs/DEPLOY.ps1:246-274`:
```powershell
# Regex-rewrite preserves existing formatting (indent, key order, trailing newline)
$pattern = '"version"\s*:\s*"' + [regex]::Escape($CurrentVersion) + '"'
$replacement = '"version": "' + $NewVersion + '"'
$newRaw = [regex]::Replace($PluginRaw, $pattern, $replacement)
if ($newRaw -eq $PluginRaw) {
    throw "Failed to locate ""version"": ""$CurrentVersion"" in plugin.json -- aborting bump"
}
[System.IO.File]::WriteAllText($PluginJson, $newRaw)

# Verify the write took
$verify = (Get-Content $PluginJson -Raw | ConvertFrom-Json).version
if ($verify -ne $NewVersion) {
    throw "plugin.json post-write reads version='$verify', expected '$NewVersion'"
}
```

**Pattern to copy for CHANGELOG.md stub-append (D-10 — slots in after line 274, before commit at line 279):**
```powershell
# Phase 34 D-10: append stub H2 to repo-root CHANGELOG.md (no commit)
$ChangelogPath = Join-Path $RepoRoot 'CHANGELOG.md'
$ChangelogRaw  = Get-Content $ChangelogPath -Raw
$todoMarker    = "TODO: changelog entry"
# Abort if PRIOR -Bump's stub for current version is still un-filled
if ($ChangelogRaw -match [regex]::Escape("## [$CurrentVersion]") -and `
    $ChangelogRaw -match [regex]::Escape($todoMarker)) {
    Write-Host "ERROR: CHANGELOG.md contains '$todoMarker' for v$CurrentVersion." -ForegroundColor Red
    Write-Host "       Fill in the entry and commit before re-running -Bump." -ForegroundColor Red
    exit 1
}
$today = Get-Date -Format 'yyyy-MM-dd'
$stub = "`n## [$NewVersion] - $today`n`n- $todoMarker`n"
$newChangelog = $ChangelogRaw.TrimEnd("`r","`n") + $stub
[System.IO.File]::WriteAllText($ChangelogPath, $newChangelog)
Write-SubStep "Appended CHANGELOG.md stub for v$NewVersion (fill in + commit before next -Bump)"
# Intentionally NOT staged/committed — gates next -Bump on author filling
```

**Analog B — sync to marketplace (Copy-Item)** at `docs/DEPLOY.ps1:359-370`:
```powershell
Invoke-Mutation "Copy owl.exe -> $MarketSpt" {
    Copy-Item -Path $OwlBinary -Destination (Join-Path $MarketSpt 'owl.exe') -Force
}
Invoke-Mutation "Copy plugin.json -> $MarketPluginMeta" {
    Copy-Item -Path $PluginJson -Destination (Join-Path $MarketPluginMeta 'plugin.json') -Force
}
```

**Pattern to copy for CHANGELOG.md sync (D-11 — `{Marketplace}/plugin/spt/CHANGELOG.md`, sibling to `plugin.json`, NOT inside `.claude-plugin/`):**
```powershell
# Phase 34 D-11: sync repo-root CHANGELOG.md to marketplace plugin root
Invoke-Mutation "Copy CHANGELOG.md -> $MarketSpt" {
    Copy-Item -Path (Join-Path $RepoRoot 'CHANGELOG.md') -Destination (Join-Path $MarketSpt 'CHANGELOG.md') -Force
}
```
**Insert site:** at line 367 (after `Copy hooks/* -> $MarketHooks`, before `Copy plugin.json`).

**Cache-tier sync** (mirror at `docs/DEPLOY.ps1:489-500` — same `Copy-Item -Force` shape):
```powershell
Invoke-Mutation "Copy CHANGELOG.md -> $CacheVer" {
    Copy-Item -Path (Join-Path $RepoRoot 'CHANGELOG.md') -Destination (Join-Path $CacheVer 'CHANGELOG.md') -Force
}
```
**Insert site:** at line 497 (after `Copy hooks/*`, before `Copy plugin.json -> $CacheVer/.claude-plugin`).

---

### MOD `.planning/REQUIREMENTS.md` + `.planning/ROADMAP.md` (config, spec doc)

**Analog:** Phase 31 D-11 + Phase 33 D-03/D-04 amendment-commit precedent. Plan 01 step 0 is a single commit that rewrites VERS-02/04/06/08/09 per CONTEXT D-12 and ROADMAP SC1/SC2/SC5 per CONTEXT D-12 — BEFORE any Rust code lands. Commit message: `docs(34): amend VERS-02..09 + SC1/2/5 for Stop-hook pivot`.

---

## Shared Patterns

### Recursion Guard (OWL_ECHO_COMMUNE)
**Source:** `src/owl/hook_idle.rs:64-69`
**Apply to:** version-change emission block + sentinel write (must precede both)
```rust
if std::env::var("OWL_ECHO_COMMUNE")
    .map(|v| !v.is_empty())
    .unwrap_or(false)
{
    return;
}
```

### Atomic File Write
**Source:** `src/common/owlery.rs:248-255`
**Apply to:** sentinel write (Stop-hook path AND `version-remind` handler)
```rust
owlery::atomic_write_string(&path, &body)
```

### Sentinel Read (graceful degrade)
**Source:** `src/owl/hook_idle.rs:71-83` pattern
**Apply to:** Stop-hook sentinel read
- Missing file → treat as first-install (write NEW, no emit)
- Malformed JSON → treat as missing (best-effort, no panic)

### Compile-Time Version
**Source:** `src/owl/poll.rs:75`, `setup.rs:35`, `live/stop.rs:199`
**Apply to:** all version comparisons in Phase 34 code
```rust
env!("CARGO_PKG_VERSION")
```
**NEVER read runtime `plugin.json`** (per VERS-02 amended wording).

### Perch-Resolution Gate
**Source:** `src/owl/hook_idle.rs:24-32` (already in place in `hook_idle.rs::run()`)
**Apply to:** version-change emission — only fires AFTER `owl_id = ... or return`. No new predicate code per D-02.

### Hook Stdin Parsing
**Source:** `src/common/hook_output.rs:26-29` (`parse_hook_stdin`)
**Apply to:** any new hook-handler stdin reads (version-change code doesn't need it — it reads no stdin fields beyond what perch-resolution already consumes).

### Test Env-Mutation Hygiene
**Source:** `src/owl/hook_idle.rs:108-135` (in-file unit tests) + `tests/auto_pick_integration.rs:46-65` (integration tests)
**Apply to:** all unit + integration tests touching `SPT_HOME`
- `static ENV_LOCK: Mutex<()> = Mutex::new(())`
- `EnvSnapshot::capture()` RAII (Drop restores)
- `cmd.env_remove(...)` for all gate-listed env vars before subprocess invocation

### DEPLOY.ps1 Mutation Wrapping
**Source:** `docs/DEPLOY.ps1:359-370` (`Invoke-Mutation "...desc..." { Copy-Item ... }`)
**Apply to:** every new file write in DEPLOY.ps1 — wrap in `Invoke-Mutation` so `-DryRun` is auto-respected and step descriptions print uniformly.

---

## Risks: Deviations from Established Patterns

1. **Stop-hook output channel — `decision:"block"` + `reason` is unprecedented in this codebase.** Every other hook (`hook_check`, `hook_prompt`, `plugin_session_start`) uses `hookSpecificOutput.additionalContext`. The file-doc comment at `hook_idle.rs:1-5` explicitly says Stop has no such schema. Phase 34 picks `decision:"block"` per CONTEXT D-04 but this is a research blocker — confirm with `~/.claude/reference_docs/claude-code-hooks.md` that `reason` text actually propagates to Claude on re-engagement. **Fallback if broken:** PreToolUse (next tool call) or revisit UserPromptSubmit. Flagged in CONTEXT Deferred.

2. **Sentinel lives at `$SPT_HOME/last-seen-version.json`, NOT under `$SPT_HOME/owlery/`.** Per VERS-01 the sentinel is a SIBLING of `owlery/`, breaking the "everything spt-related lives under `owlery_dir()`" convention. There is no existing precedent for files at `spt_home()` root. Other dirs (`bin/`, `psyches/`, `pulses/`, `status/`, `logs_latest/`) are all subdirs. CONTEXT locks this; the sentinel is intentionally OUT of owlery to avoid coupling with perch lifecycle (e.g., `owl cleanup`). Add helper `pub fn sentinel_path() -> PathBuf { spt_home().join("last-seen-version.json") }` to `owlery.rs` to keep the convention encapsulated.

3. **`version-remind` is the first subcommand whose only purpose is to mutate a single sentinel file.** Most subcommands wrap richer logic (`Setup`, `Poll`, `Live`, `Send`). The closest precedent is `NewAlarm` (one-shot file write) but `NewAlarm` also threads through resolve + pulse-dir registration. `version-remind` is ~10 lines and a single fs write — keep it tiny; don't over-engineer.

4. **No `regex` crate currently in `Cargo.toml`** (verify during planning). The CHANGELOG.md H2 parser regex is small enough to hand-roll (5-10 lines, mirror the parser style at `tests/skill_hints.rs:29-56` for zero-dep parsing). Adding `regex` for this one use would be a heavy dep; planner should default to hand-rolled.

5. **DEPLOY.ps1 stub-injection does not commit.** Every other DEPLOY.ps1 mutation either commits immediately (`-Bump` at line 281) or is part of the marketplace push (line 403). The Phase 34 CHANGELOG.md stub-append is deliberately uncommitted to gate the next `-Bump` on author action — this is a NEW interaction shape for DEPLOY.ps1. Surface it clearly in the post-bump notice so users aren't surprised by `git status` showing dirty CHANGELOG.md after `-Bump`.

6. **CHANGELOG.md is read at runtime from `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` rather than embedded via `include_str!`** (D-11). `psyche.md` is the established precedent and IS embedded. CONTEXT calls out the rationale: changelog grows monotonically, binary should not bloat. Tradeoff: runtime file read can fail (missing file, IO error) → must degrade gracefully (skip emission, log silently — mirror the `hook_idle.rs:71-83` read-then-bail pattern).

7. **AskUserQuestion + `$OWL version-remind <old>` round-trip relies on Claude faithfully passing the literal `old` value from the block payload.** No structural guarantee — purely instructional. Block-body MUST surface the exact invocation string verbatim with `<old>` substituted (CONTEXT D-08). The only enforcement is the `version-remind` subcommand rejecting a missing arg.

---

## Metadata

**Analog search scope:** `src/owl/`, `src/common/`, `src/live/`, `tests/`, `docs/DEPLOY.ps1`, `src/cli.rs`, `src/main.rs`
**Files scanned:** 12 read in full or in targeted ranges
**Pattern extraction date:** 2026-05-17
