---
quick_id: 260517-rhw
slug: spt-first-install-primer-flow
date: 2026-05-18
status: ready-for-planning
---

# Quick Task 260517-rhw: SPT First-Install Primer Flow — Research

**Researched:** 2026-05-18
**Confidence:** HIGH (all reuse points verified in source; Phase 34 plumbing is shipped and live)

## Summary

The primer flow is a near-direct clone of Phase 34's version-change-changelog plumbing, with three differences: (a) bound to **PreToolUse** (`hook-check` → `src/owl/hook_check.rs`) rather than Stop; (b) sentinel-absent path is the **emit path** (Phase 34 treats absent as silent first-install rewrite); (c) AUQ has 2 options (Yes/Skip) instead of 3.

The Phase 34 hotfix pivot **deleted** `decision:"block"` as a delivery transport — the primer MUST use the same surviving transport: enqueue an informational owl message via `spool_message_deferred`, let `format_owl_messages` tag it `priority="info"`, and let the next `hook-check` PreToolUse drain it via the universal spool drain. Because the primer's natural hook is **already** PreToolUse, the message will surface on the next tool use of the same session — no Stop-hook scheduling needed.

**Primary recommendation:** Reuse `version_changelog::write_sentinel` verbatim for the sentinel write (it produces the exact same `{"version":"X.Y.Z"}` shape the version-change flow checks against, satisfying CONTEXT D-05 "same code path"). Add a sibling helper `maybe_emit_primer_block(target_owl_id)` in a new `src/owl/primer.rs` that mirrors `maybe_emit_version_change_block`'s structure but inverts the sentinel-absent branch. Wire from `hook_check::handle_parent` immediately after perch resolution. No new transport, no new XML envelope shape — borrow `<spt-primer>` as a sibling tag of `<spt-version-changelog>` and extend `is_informational` accordingly.

---

## User Constraints (from CONTEXT.md)

### Locked Decisions
- **Sentinel:** `$SPT_HOME/last-seen-version.json` presence = "primer already shown". First install = file absent. Both Yes and Skip MUST write the sentinel via the **same code path** as Phase 34 (i.e. `version_changelog::write_sentinel(current_version())`).
- **Delivery:** PreToolUse hook (NOT Stop) injects `additionalContext` → AUQ on next turn.
- **Suppression:** Both Yes and Skip suppress for all subsequent sessions.
- **Primer text:** exact slash commands verbatim (`/live`, `/clear`, `/commune`, `/list-agents`, `/send`, `/signoff`); ANSI cyan (owl) / orange (live) palette; header `--- SPT Getting Started ---`.

### Claude's Discretion
- XML envelope shape (planner picks `<spt-primer>` vs `<spt-getting-started>` vs other).
- Whether the primer body is fully built in Rust (`build_primer_reason`) or assembled by Claude from a short instructions block. Researcher recommends **fully built in Rust** to keep colors and command list exact.
- 2-option AUQ wording exact text.

### Deferred Ideas (OUT OF SCOPE)
- Reminding the user about specific skills later.
- Tracking which primer-version was shown (no need — single primer body, never re-shown).
- Primer localization.

---

## Per-Question Findings

### Q1. Sentinel file `last-seen-version.json` — already exists, same name, exact reuse

**Status:** Already implemented by Phase 34. The CONTEXT.md sentinel choice IS the version-change sentinel; no parallel file.

- **Path resolver:** `owlery::sentinel_path()` at `src/common/owlery.rs:116-118`:
  ```rust
  pub fn sentinel_path() -> PathBuf {
      spt_home().join("last-seen-version.json")
  }
  ```
- **Schema:** `{"version":"X.Y.Z"}` (semver-only). Hand-formatted literal in `version_changelog::write_sentinel` at `src/owl/version_changelog.rs:112-115`:
  ```rust
  pub fn write_sentinel(version: &str) -> std::io::Result<()> {
      let body = format!("{{\"version\":\"{}\"}}", version);
      owlery::atomic_write_string(&sentinel_path(), &body)
  }
  ```
- **Reader:** `version_changelog::read_sentinel()` at `src/owl/version_changelog.rs:98-106`. Returns `None` on missing OR malformed OR non-semver value (REVIEW-FIX #7 hardening).
- **Atomic write:** `owlery::atomic_write_string(&Path, &str)` at `src/common/owlery.rs:255-262` — tmp + rename, NTFS+POSIX safe.

**Implication for primer:** Sentinel absent = first install for BOTH the primer and the version-changelog flow. The primer handler MUST call `version_changelog::write_sentinel(&version_changelog::current_version())` after Yes/Skip — this is byte-identical to the Phase 34 "first-install silent rewrite" path at `version_changelog.rs:437-443`, which means once the primer handler writes the sentinel, the version-change Stop hook on the very next idle WILL see a matching sentinel and emit nothing. **This satisfies CONTEXT D-05 ("prevents the user from immediately being hit by the version-change banner on the very next `/clear`")** automatically — no separate plugin-dir version write is needed because the version-change flow's only persistent sentinel is `last-seen-version.json` itself.

### Q2. "Plugin-dir version sentinel" — there is no separate file; the same `last-seen-version.json` is the only one

**Status:** CLARIFICATION. CONTEXT.md mentions "plugin-dir version sentinel update" alongside `last-seen-version.json`, but in the shipped Phase 34 implementation **there is only one sentinel file** — `$SPT_HOME/last-seen-version.json`. It is sibling to `owlery/`, NOT inside the plugin dir.

Verified:
- `owlery::sentinel_path()` at `src/common/owlery.rs:116-118` puts the file under `spt_home()` (i.e. `%LOCALAPPDATA%\spt\` on Windows or `~/.spt/` on Unix), NOT under `$CLAUDE_PLUGIN_ROOT`.
- The plugin-dir path `$CLAUDE_PLUGIN_ROOT/CHANGELOG.md` IS read by Phase 34 (via `version_changelog::resolve_changelog_path()` at `src/owl/version_changelog.rs:261-272`) but it is **never written** by the runtime — it is a deploy-time artifact synced by `docs/DEPLOY.ps1`.
- There is no other version-tracking file in the plugin dir (`plugin/spt/.claude-plugin/plugin.json` is the build-time version of record, written only by `DEPLOY.ps1 -Bump`, never by the binary at runtime).

**Implication for primer:** The CONTEXT.md phrasing "AND updates the plugin-dir version sentinel" is misaligned with the shipped Phase 34 — there is no second file to update. A single `version_changelog::write_sentinel(&current_version())` call satisfies the full intent (both "primer shown" + "version-change suppression on next /clear"). **The planner should flag this with the user during plan write-up to confirm the single-write interpretation is acceptable** (high confidence it is — the user-stated intent is the no-immediate-version-banner outcome, which one write achieves).

### Q3. PreToolUse hook scaffolding — `hook-check` → `output_hook_response`

**Status:** Existing scaffolding ready for reuse. Entry-point command: `$CLAUDE_PLUGIN_ROOT/owl.exe hook-check` (declared in `plugin/spt/hooks/hooks.json:23-31`).

- **Dispatcher:** `src/owl/hook_check.rs::run()` (lines 11-32). Parses stdin, branches on subagent vs parent, calls `handle_parent` or `handle_subagent`.
- **JSON shape emitted (already standard across all hooks):** `output_hook_response("PreToolUse", &context)` at `src/common/hook_output.rs:378-386`:
  ```rust
  pub fn output_hook_response(event_name: &str, additional_context: &str) {
      let response = serde_json::json!({
          "hookSpecificOutput": {
              "hookEventName": event_name,
              "additionalContext": additional_context
          }
      });
      println!("{}", serde_json::to_string(&response).unwrap());
  }
  ```
- **Existing injection site:** `handle_parent` at `src/owl/hook_check.rs:45-113` already builds a `Vec<String> context_parts` and emits via `output_hook_response` if non-empty. Phase 34 hotfix-260517-6om uses a different mechanism — `spool_message_deferred` to Self's perch (the message is then auto-drained by this same `hook-check` handler at lines 60-93 via `spool::peek_all` + `format_owl_messages` + universal additionalContext injection).

**Two viable insertion patterns for the primer:**

- **Pattern A (matches Phase 34 owl-transport):** New helper `primer::maybe_emit_primer_message(owl_id)` enqueues via `spool::spool_message_deferred(owl_id, "spt-primer", body, &owlery)` after perch resolution (insertion site `hook_check.rs:50` immediately after `let owl_id = ...`). The existing spool drain at lines 60-93 then surfaces it on the SAME hook invocation. **Inherits Phase 34's information-banner classification automatically** if `is_informational` is extended (see Q4).
- **Pattern B (direct context injection):** Push primer body directly into `context_parts` at `hook_check.rs:88` (sibling to the spool-drained msg_text). Bypasses spool entirely. Simpler but loses the "informational vs high-priority" routing already wired through `format_owl_messages`.

Researcher leans Pattern A — it is the exact pattern Phase 34's hotfix-260517-6om landed for the version-change message, and it reuses the priority routing tested in defect-2 of quick-260517-n4b.

### Q4. AUQ injection pattern (Phase 34) — XML body inside `additionalContext`, no special handshake

**Status:** Confirmed. Phase 34 (post-hotfix) delivers the AUQ instructions as an inert XML body wrapped in `<owl_message>` → `<owl_messages>` → `additionalContext`. Claude reads the XML, follows the inline instructions, fires AUQ on its next turn.

**Exact shape (from production code at `src/owl/version_changelog.rs:389-401` → wrapped by `src/common/hook_output.rs:340-368`):**

```xml
<owl_messages>
<owl_message from="version-change" priority="info"><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>
  <instructions>
Render an AskUserQuestion to confirm whether the user wants to see the changelog ...

Then invoke AskUserQuestion with EXACTLY these three options (labels verbatim, do not paraphrase):
  1. "Yes, full changelog" — On selection: ...
  2. "Yes, highlights only" — On selection: ...
  3. "Skip" — On selection: ...
  </instructions>
</spt-version-changelog></owl_message>
</owl_messages>

[OWL SYSTEM] Informational owl messages above — no acknowledgment needed. Continue your current task.
```

The trailer banner `[OWL SYSTEM] Informational` is appended by `hook_output::format_owl_messages` at `src/common/hook_output.rs:359` when **every** message tests `true` via `is_informational(body)`.

**For the primer to inherit the calm banner**, `is_informational` at `src/common/hook_output.rs:329-331` must be extended:

```rust
pub(crate) fn is_informational(body: &str) -> bool {
    body.contains("[WORKING_PERCH_NOTICE]")
        || body.contains("<spt-version-changelog>")
        || body.contains("<spt-primer>")   // ADD
}
```

This is critical — without it the primer surfaces with the alarming `[OWL SYSTEM - HIGHEST PRIORITY] STOP your current task` banner that defect-2 of quick-260517-n4b fixed. **The planner MUST include this extension as a single-line edit + matching test.**

### Q5. Silent-owl decision-block helper — does NOT exist as a separate helper; the "owl message transport" IS the decision-block replacement

**Status:** There is no `decision:block` helper to reuse — Phase 34 hotfix-260517-6om **deleted** `decision:"block"` as a transport entirely (per `hook_idle.rs:8-15` comment: "Stop hook is now silent, but the early-return preserves the existing ordering invariant").

The "silent owl decision-block rework" referenced in CONTEXT.md is the pivot from a Stop-hook block envelope to **`spool_message_deferred` → `peek_all` → `format_owl_messages` → `additionalContext`**. That entire chain is:

| Step | Function | File:Line |
|------|----------|-----------|
| Enqueue informational msg | `spool::spool_message_deferred(target_id, from_id, body, owlery)` | `src/common/spool.rs:73-81` |
| Hook drain | `spool::peek_all(owl_id, &owlery_dir)` | called from `hook_check.rs:64` |
| Parse to `(from, body)` tuples | `hook_output::parse_messages(&raw)` | called from `hook_check.rs:78` |
| Wrap + classify priority | `hook_output::format_owl_messages(&messages)` | `src/common/hook_output.rs:340-368` |
| Inject as additionalContext | `hook_output::output_hook_response("PreToolUse", &ctx)` | `src/common/hook_output.rs:378-386` |
| Mark delivered | `spool::mark_delivered(owl_id, &msg_ids, &owlery)` | called from `hook_check.rs:83` |

**Reusability for primer:** Pattern A above (`spool_message_deferred(owl_id, "spt-primer", body, &owlery)`) gets every step in this chain for free. The primer is structurally **simpler** than the version-changelog message — no need for the Yes-full/Yes-highlights branching, no CHANGELOG.md to read, no `step_count`. The handler does **not** need a separate decision-block helper.

### Q6. ANSI color palette — owl cyan, live orange, both defined in `src/common/output.rs`

**Status:** Confirmed. Constants at `src/common/output.rs:6-7`:

```rust
pub const C_CYAN:   &str = "\x1b[36m";        // owl
pub const C_ORANGE: &str = "\x1b[38;5;208m";  // live (256-color)
```

Plus `C_DIM`, `C_RESET`, and helper printers `print_status` (cyan), `print_status_dim` (dim cyan), `print_live_status` (orange), `print_live_status_dim` (dim orange) at `src/common/output.rs:18-43`.

**Recommendation for the primer body:**
- The primer text is delivered as an XML body inside `additionalContext` — it surfaces in Claude's tool-call context, NOT directly on the user's terminal. ANSI escape sequences embedded in the `additionalContext` JSON value will be rendered as literal text by Claude's UI (the user sees `\x1b[36m`-style noise, not color).
- The CONTEXT.md "ANSI color coding" requirement is therefore **misaligned with the additionalContext delivery channel**. There are two ways to honor the user's intent:
  - **Option 1 (recommended):** The Rust `build_primer_body()` includes a brief instruction at the top of `<instructions>` telling Claude to **render the body inline in its response** using markdown formatting (bold headers, numbered list) and to surface the title `--- SPT Getting Started ---` as a heading. No ANSI escapes — Claude's response is what the user sees, and Claude's UI renders markdown natively. Use **emphasis/markdown** as the "color" proxy.
  - **Option 2:** Embed the ANSI codes literally. Will display as escape sequences in the chat UI — likely not what the user wants. Rejected unless user reconfirms.

The planner should flag this to the user. Likely outcome: rendered as markdown.

If Option 2 is later requested for a direct-print code path (e.g. via Bash tool), the constants are ready: `C_CYAN` for `--- SPT Getting Started ---` header and command names; `C_ORANGE` for live-related lines (`/live`, "live agent"); `C_RESET` after each segment.

### Q7. Pitfalls — Phase 34 hotfix history

Phase 34 shipped Plans 01/02/03 as planned, then needed two hotfix rounds. The primer flow inherits all of these risks.

**Hotfix 260517-6om (v1.10.12) — "Stop hook blocking error" surfaced as user-visible Claude Code error.**

The original Phase 34 design emitted `{"decision":"block","reason":"..."}` from the Stop hook on stdout. Claude Code surfaced this as a high-priority "Stop hook blocking error" banner that competed with normal user-turn flow. The fix: pivot to `spool_message_deferred` informational message. **The primer MUST NOT attempt to use `decision:block` on PreToolUse** — Anthropic's PreToolUse `decision:block` shape rejects the tool use entirely; it is even less appropriate than Stop's block. Use the spool-deferred owl-message transport.

**Hotfix 260517-n4b (v1.10.13) — three defects:**

1. **`step_count` off-by-one floor** — graceful-degrade arithmetic accidentally rendered `<step_count>0</step_count>` when CHANGELOG.md was missing the new-version H2. Fix: `let step_count = step_count.max(1);` at `src/owl/version_changelog.rs:501`. **Primer is immune** — there is no step-count math.

2. **Info-priority reclassification** — the version-change message was getting the alarming `[OWL SYSTEM - HIGHEST PRIORITY] STOP your current task` banner instead of the calm `[OWL SYSTEM] Informational` banner. Fix: extend `is_informational` to recognize `<spt-version-changelog>`. **THE PRIMER WILL HIT THIS BUG VERBATIM if `is_informational` is not extended for `<spt-primer>`.** See Q4 — this is a single-line edit but cannot be omitted.

3. **DEPLOY.ps1 curation gate** — `-Bump` was committing plugin.json + Cargo.toml WITHOUT requiring a curated CHANGELOG entry, then the stub-append ran after the commit (so the stub was left dangling unrelated to the bump). Fix landed in commit `c99d7c9`: requires curated `## [NEW_VERSION]` H2 before mutation. **Not relevant to the primer** — the primer flow does not touch CHANGELOG.md or DEPLOY.ps1.

**Additional Phase 34 pitfall to inherit:**

- **`OWL_ECHO_COMMUNE` recursion guard** — the haiku echo-commune child process should NOT see the primer prompt either (it is not the user; it is an internal subprocess). Mirror the guard from `hook_idle.rs:60-64`:
  ```rust
  let skip_primer = std::env::var("OWL_ECHO_COMMUNE")
      .map(|v| !v.is_empty()).unwrap_or(false);
  ```
  Insert this check before the primer fires in `hook_check.rs::handle_parent`. PreToolUse may fire inside an echo-commune child if the child invokes any tool — confirm during plan write-up.

- **`stop_hook_active` is not relevant to PreToolUse** — that flag exists only on Stop hook stdin; no equivalent on PreToolUse. No infinite-loop pathology because PreToolUse does not re-enter on its own; the sentinel write ensures next PreToolUse sees a matching sentinel and skips.

- **Subagent perches are auto-skipped** by current `hook_check.rs::handle_parent`: subagents go down the `handle_subagent` branch (line 27). Primer logic in `handle_parent` will not fire for subagents (the primer is a one-shot user-facing thing; subagents should not see it). Confirm by inspecting the `if let Some(agent_id) = &stdin.agent_id` branch at `hook_check.rs:25`.

- **Working-perch regression (from MEMORY.md):** `project_working_perch_notice_deferred_regression.md` notes that working-perch notices wake Self's poll listener even though they should be deferred. This is a known existing regression — the primer's owl message will inherit the same behavior. Since the primer's `target_owl_id` IS Self (not a worker), the regression does not apply: there is no worker poll listener to wake.

- **PreToolUse fires often** — every tool call. The fast-path guard pattern (check sentinel presence cheaply BEFORE doing any work) is mandatory. `version_changelog::read_sentinel()` is a single fs::read + JSON parse; acceptable cost per call. But the primer handler should short-circuit immediately on `read_sentinel().is_some()` — never read CHANGELOG.md, never re-enqueue.

---

## Recommended Surface Area (for planner)

**New file:** `src/owl/primer.rs` (mirrors `src/owl/version_changelog.rs` structure but much smaller — likely ~60 lines).

**Public functions:**
- `pub fn maybe_emit_primer_message(target_owl_id: &str) -> PrimerPrompt` — returns `Emitted | NotEmitted | AlreadyShown`.
- `pub fn build_primer_body() -> String` — produces the `<spt-primer>...</spt-primer>` XML body with `<instructions>` for Claude to render the 2-option AUQ + the canonical 5-step blurb on Yes.

**Edits to existing files:**
- `src/common/hook_output.rs:329-331` — extend `is_informational` for `<spt-primer>` substring (1 line + 1 unit test).
- `src/owl/hook_check.rs::handle_parent` (~line 50) — invoke `primer::maybe_emit_primer_message(&owl_id)` after `find_perch_cached`, before idle-ready clear.
- `src/owl/mod.rs` — register `pub mod primer;`.

**Reused without modification:**
- `version_changelog::write_sentinel(&str)` — for the post-AUQ sentinel write (CONTEXT D-05 "same code path").
- `version_changelog::read_sentinel() -> Option<String>` — for the gate check at top of `maybe_emit_primer_message`.
- `version_changelog::current_version() -> String` — for the value to write.
- `spool::spool_message_deferred(target, from, body, &owlery)` — for delivery.
- `owlery::owlery_dir()` — for the spool target dir argument.

**Wiring sketch:**
```rust
// src/owl/primer.rs (skeleton)
pub enum PrimerPrompt { Emitted, AlreadyShown }

pub fn maybe_emit_primer_message(target_owl_id: &str) -> PrimerPrompt {
    // OWL_ECHO_COMMUNE guard (mirrors hook_idle.rs:60-64)
    if std::env::var("OWL_ECHO_COMMUNE").map(|v| !v.is_empty()).unwrap_or(false) {
        return PrimerPrompt::AlreadyShown;
    }
    // Sentinel present = primer already shown (or version-change already wrote it)
    if crate::owl::version_changelog::read_sentinel().is_some() {
        return PrimerPrompt::AlreadyShown;
    }
    let body = build_primer_body();
    let owlery = crate::common::owlery::owlery_dir();
    let _ = crate::common::spool::spool_message_deferred(
        target_owl_id, "spt-primer", &body, &owlery
    );
    PrimerPrompt::Emitted
    // NOTE: sentinel is NOT written here — it is written by Claude after
    // the AUQ Yes/Skip resolves. The instructions body MUST tell Claude
    // to invoke a Bash command that writes the sentinel via either:
    //   (a) a new `$OWL primer-ack` subcommand (clean), or
    //   (b) directly calling the existing version-remind path (hacky).
    // Recommendation: add `$OWL primer-ack` subcommand — 5-line handler
    // that calls version_changelog::write_sentinel(&current_version()).
}
```

**Open planner decision:** Whether the sentinel write happens server-side at emit time (simpler — primer shown once even if AUQ is dismissed by the user closing the session) or client-side after AUQ resolution (matches CONTEXT D-05 more literally). Server-side has the desirable property that a closed-then-reopened session does not re-prompt. **Researcher recommends server-side write at emit time** (call `write_sentinel(&current_version())` immediately before enqueue) and treat the AUQ as a UX nicety, not a state machine. The user is unlikely to want re-prompting if they accidentally close the session.

---

## Sources

### Primary (HIGH confidence — all production source verified)
- `src/common/owlery.rs:116-118` — `sentinel_path()`
- `src/common/owlery.rs:255-262` — `atomic_write_string`
- `src/common/output.rs:6-7, 18-43` — ANSI palette constants + printers
- `src/common/hook_output.rs:329-331` — `is_informational` (extension point)
- `src/common/hook_output.rs:340-368` — `format_owl_messages` (info-banner switch)
- `src/common/hook_output.rs:378-386` — `output_hook_response` (additionalContext shape)
- `src/common/spool.rs:67-81` — `spool_message_deferred`
- `src/owl/hook_check.rs:11-113` — PreToolUse dispatcher + `handle_parent`
- `src/owl/hook_idle.rs:8-89` — Phase 34 wiring reference + recursion-guard idiom
- `src/owl/version_changelog.rs` — full Phase 34 module to mirror
- `plugin/spt/hooks/hooks.json` — hook event → owl.exe subcommand mapping

### Secondary
- `.planning/phases/34-version-change-changelog/34-RESEARCH.md` lines 124-220 (Q1-Q7 protocol — confirms Stop-hook `decision:block` semantics, now superseded)
- `.planning/phases/34-version-change-changelog/34-03-SUMMARY.md` lines 141-174 (UAT amendment — hotfix-6om + hotfix-n4b detail)

### Project memory
- `MEMORY.md` → `project_working_perch_notice_deferred_regression.md` — known regression that does NOT affect the primer (primer target is Self, not a worker).

---

## Confidence Breakdown

| Area | Confidence | Reason |
|------|-----------|--------|
| Sentinel reuse path | HIGH | Production code read end-to-end; tests exist |
| PreToolUse hook scaffold | HIGH | Same hook drains spool today (version-change post-hotfix) |
| `is_informational` extension requirement | HIGH | Defect-2 of n4b is verbatim precedent |
| Single-vs-two sentinel question | HIGH (CONTEXT.md misnames second sentinel; only one file exists in shipped code) — flag to user before plan execution |
| ANSI vs additionalContext delivery mismatch | HIGH (escapes will literal-render in Claude's UI) — flag to user; recommend markdown |
| Sentinel write timing (emit-time vs post-AUQ) | MEDIUM — both work; researcher recommends emit-time, planner should confirm with user |

## Ready for Planning

Three reuse points are confirmed and ready: (1) `version_changelog::write_sentinel` for the sentinel write, (2) `spool_message_deferred` for delivery, (3) `is_informational` extension for the calm banner. One new file (`src/owl/primer.rs`), one new subcommand if the planner picks post-AUQ sentinel write (`$OWL primer-ack`), and two single-line edits to existing files. Plan likely fits in 2-3 tasks.
