# Phase 33: Fresh-Start Commune + Auto-Resume — Pattern Map

**Mapped:** 2026-05-17
**Files analyzed:** 6 (3 modify, 1 new test or test-submodule, 2 doc-amend)
**Analogs found:** 6 / 6 (all exact or strong role-match)

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `plugin/spt/skills/live/SKILL.md` | skill (Claude instructions) | request-response + dispatch-table | self (existing Step 2 dispatch SKILL.md:136-151) + `plugin/spt/skills/list-live/SKILL.md` (description frontmatter style) | exact |
| `src/owl/plugin_session_start.rs` | hook handler (Rust) | event-driven (SessionStart stdin → additionalContext stdout) | self (`inject_reorientation_if_needed` plugin_session_start.rs:115-181) + `src/owl/resume.rs::should_skip_resume_from_input` (pure predicate seam, resume.rs:21-45) | exact (same file + sibling-module pattern) |
| `tests/skill_hints.rs` | test (regression-guard) | batch / pinned-value assertion | self (existing `argument_hint_keys_known_set` skill_hints.rs:147-199) | exact (extend in-place) |
| (New submodule) `auto_pick_predicate_tests` inside `src/owl/plugin_session_start.rs` | unit test (pure-predicate corpus) | batch / table-driven boolean assertion | `dispatch_tests` submodule plugin_session_start.rs:890-1080 + `snapshot_tests` submodule plugin_session_start.rs:725-887 | exact (sibling pattern in same file) |
| (New) `tests/auto_pick_integration.rs` | integration test (subprocess wire) | event-driven (spawn `owl plugin-session-start`, assert stdout) | `tests/plugin_session_start_refresh.rs` + `tests/plugin_session_start_psyche_context.rs` (existing subprocess pattern) | strong role-match |
| `.planning/REQUIREMENTS.md` + `.planning/ROADMAP.md` | docs amendment | doc-only commit | Phase 31 D-11 amendment commit (REQUIREMENTS amend pattern); Phase 32 LIST-04 sibling | exact |

---

## Pattern Assignments

### `plugin/spt/skills/live/SKILL.md` — FRESH-02 NO-CONTEXT predicate + `--auto` Step + description rewrite

**Analog A (in-file): existing Step 2 dispatch table** — `plugin/spt/skills/live/SKILL.md:136-151`

**Existing dispatch shape (preserve the structure when inserting Auto-resume Step)**:
```markdown
### Step 2: Dispatch on `kind`

- **`kind: "auto"`** — exactly one offline agent in this repo's history. Run:
  ```bash
  $LIVE start <id>
  ```
  where `<id>` is the JSON's `id` field. Tell the user: *"Auto-launching `<id>` (only known agent in this repo)."*

- **`kind: "pick"`** — 2+ offline agents. Fire `AskUserQuestion` using the JSON's `header`, `question`, `options`, and `body_addendum` fields verbatim. Do NOT add an explicit "Other" option — `AskUserQuestion` already provides a native free-text "Other" input; an extra option duplicates it.

- **`kind: "prompt-new"`** — 0 offline agents. Same `AskUserQuestion` pattern, but the `options` are starter role-name suggestions and `body_addendum` lists the full starter pool. `AskUserQuestion`'s native free-text is used; do not add an explicit "Other" option.
```

The Phase 33 Auto-resume Step (§2.1 of RESEARCH.md) MUST mirror this bullet-per-kind shape so the dispatcher reading the skill sees a uniform table.

**Analog B (in-file): existing Cancel-handling forced-picker block** — `plugin/spt/skills/live/SKILL.md:176-192`

This is the reference for AUTO-07's "always confirm" rule. The existing forced-picker rule already establishes the pattern of "even for `kind:"auto"`, fire `AskUserQuestion` before launching" — Phase 33 D-09 extends the same rule to the `--auto` path.

Excerpt (SKILL.md:184-189, "kind:auto synthesize AskUserQuestion manually"):
```markdown
- **`kind:"auto"`** — synthesize an `AskUserQuestion` manually:
  - `header`: `"Choose live agent"`
  - `question`: `"Which agent should /spt:live use?"`
  - `options`: a single option whose `label` is the JSON's `id` field
  - `body_addendum`: `"Known agent in this repo:\n- <id>\n\nOr type a different name using Other."` (substitute `<id>` from JSON)
  - Do NOT run `$LIVE start <id>` until the user explicitly selects that option in the `AskUserQuestion`.
```

Reuse this exact AskUserQuestion shape (header/question/options/body_addendum) for AUTO-07's confirmation hop on `kind:"auto"` and `kind:"pick"`. Substitute language for "Resume {id} (last active {desc})?".

**Analog C (cross-skill): description frontmatter precision** — `plugin/spt/skills/list-live/SKILL.md:1-8`

```yaml
---
name: list-live
description: |
  Show active live agents. Use when the user asks "list live agents",
  "who's live", or "show live".
argument-hint: "[--all] [--offline] [--here]"
allowed-tools: [Bash]
---
```

This is the Phase 32 HINT-04 / LIST-04 style: enumerate explicit user phrases in the description block. Phase 33's D-07/D-08 description rewrite expands this pattern with EIGHT positive triggers AND THREE explicit non-triggers (negative examples) — same structural approach, larger phrase corpus.

**Analog D (in-file): existing absorb-context instruction** — `plugin/spt/skills/live/SKILL.md:100-103`

```markdown
- If content is current, no action needed.
- If context has information you lack (e.g., after `/clear`), absorb it.
- If stale or missing recent work, send a commune to update Psyche.
- If `NO-CONTEXT`, both starting fresh.
```

This is the existing NO-CONTEXT handling site. Phase 33 FRESH-02 attaches HERE — modify the "If `NO-CONTEXT`" bullet to branch into the first-commune AskUserQuestion flow. Single ordering point per RESEARCH §8.3 / Assumption A6.

---

### `src/owl/plugin_session_start.rs` — AUTO-03 `<spt-live-auto-pick>` emission + predicate

**Analog A (in-file, primary): `inject_reorientation_if_needed`** — `plugin_session_start.rs:115-181`

Imports + struct + parse pattern (lines 115-148):
```rust
fn inject_reorientation_if_needed(input: &str, prior_session_id: Option<&str>) -> bool {
    if input.is_empty() {
        return false;
    }

    #[derive(serde::Deserialize, Default)]
    struct SourceInput {
        #[serde(default)]
        source: Option<String>,
        #[serde(default)]
        agent_type: Option<String>,
    }

    let parsed: SourceInput = match serde_json::from_str(input) {
        Ok(p) => p,
        Err(_) => return false,
    };

    // Skip agent-mode sessions (psyche wrapper, etc.)
    if parsed.agent_type.is_some() {
        return false;
    }
    // Skip team members
    if std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok() {
        return false;
    }

    let source_token: &str = match parsed.source.as_deref() {
        Some("clear") => "clear",
        Some("compact") => "compact",
        _ => return false,
    };
```

**Phase 33 application**: `should_emit_auto_pick` reuses the `SourceInput` struct shape verbatim (same `source` + `agent_type` fields), and the agent_type/team-name guards become two of the predicate gates. The source-token match arm inverts: accept ONLY `"startup"` (return false on anything else). The pure-predicate refactor extracts `EnvSnapshot` so env-var reads happen outside the predicate body for testability — sibling pattern to the existing `EnvSnapshot` struct in `snapshot_tests` (plugin_session_start.rs:740-757).

**Wire-emission analog: `super::resume::inject_reorientation` hookSpecificOutput envelope** — `src/owl/resume.rs:415-424`:
```rust
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 33 application**: `emit_auto_pick(pick_spec_json: &str)` builds an identical envelope, substituting `additionalContext` content with `format!("<spt-live-auto-pick>\n{}\n</spt-live-auto-pick>", pick_spec_json)`. Use the exact same `.unwrap_or_else(|_| ...)` fallback pattern. Resolves RESEARCH Assumption A1 / Pitfall 2 — bare-XML println is NOT used; envelope JSON is the single, consistent emission shape.

**Analog B (sibling-module): pure-predicate factoring** — `src/owl/resume.rs:21-45` (`should_skip_resume_from_input`):
```rust
fn should_skip_resume_from_input(input: &str) -> bool {
    if std::env::var("CLAUDE_CODE_TEAM_NAME").is_ok() {
        return true;
    }
    if std::env::var("OWL_SKIP_RESUME").is_ok() {
        return true;
    }
    if !input.is_empty() {
        if let Ok(hook) = serde_json::from_str::<HookInput>(input) {
            if hook.agent_type.is_some() {
                return true;
            }
        }
    }
    false
}
```

**Phase 33 application**: same return-true-on-gate-hit shape, but Phase 33 extracts env reads into `EnvSnapshot::capture()` so the predicate is pure (testable without env mutation). The structural mirror is: a series of guard clauses, each returning the rejection value, with the happy-path return at the end. AUTO-03 inverts the boolean (`should_emit_auto_pick` returns `true` to fire), but the shape is identical.

**Call-site wiring pattern: `run()` entry** — `plugin_session_start.rs:10-46`:
```rust
pub fn run() {
    write_env_vars();
    // ... stdin read + session_id work ...

    // Step 6: Check source field -- clear/compact get re-orientation instead of resume
    if inject_reorientation_if_needed(&input, prior_session_id.as_deref()) {
        return; // Re-orientation injected, skip resume
    }

    // Step 6: Delegate to session-resume with pre-read input
    super::resume::run_with_input(&input);
}
```

**Phase 33 application**: AUTO-03 emission sits BETWEEN `inject_reorientation_if_needed` (line 40, which short-circuits with `return` on clear/compact) and `super::resume::run_with_input(&input)` (line 45). The new call shape:
```rust
if inject_reorientation_if_needed(&input, prior_session_id.as_deref()) {
    return;
}

// Phase 33 AUTO-03: emit <spt-live-auto-pick> on fresh startups only
let env = EnvSnapshot::capture();
let src = parse_source_input(&input);
let ppid_has_perch = crate::common::hook_output::find_perch_by_parent_pid().is_some();
if should_emit_auto_pick(&env, &src, ppid_has_perch) {
    let pick_json = serde_json::to_string(&crate::live::pick_spec::build_spec(None))
        .unwrap_or_default();
    emit_auto_pick(&pick_json);
}

super::resume::run_with_input(&input);
```

Critical: AUTO-03 does NOT `return` after emission — it falls through to `run_with_input` so the standard active-perch/dead-perch resume logic still fires. AUTO-03's predicate gate `parent_pid_has_active_perch == false` already ensures `run_with_input` won't emit `<owl-active-perch>` (which only fires for the matching ppid). The two emissions are temporally disjoint per RESEARCH §3a.

---

### `auto_pick_predicate_tests` submodule (new) inside `src/owl/plugin_session_start.rs`

**Analog: `dispatch_tests` submodule** — `plugin_session_start.rs:890-1080`

Submodule header pattern (lines 890-901):
```rust
#[cfg(test)]
mod dispatch_tests {
    //! Phase 29 Plan 02 Task 2: FIRE_ECHO_COMMUNE_NOW dispatch tests.
    //!
    //! Drives the helper layer (build_fire_echo_commune_body) rather than
    //! the full `inject_reorientation_if_needed` resolver chain — the seam
    //! returns `Option<String>` so panic-free, network-free unit tests can
    //! assert wire-format correctness, UUID validation, and source-order
    //! pinning (Test F).

    use super::*;
```

Test naming convention (clear behavioral labels, e.g.):
```rust
#[test]
fn build_body_test_a_source_clear_happy_path() { ... }
#[test]
fn build_body_test_b_source_compact_happy_path() { ... }
#[test]
fn build_body_test_e_source_match_arm_only_allows_clear_or_compact() { ... }
```

**Phase 33 application**: new submodule `auto_pick_predicate_tests` at end of file, mirroring this structure. Each of the 15+ should-fire and 17+ should-NOT-fire cases gets a descriptive `#[test] fn` name. Helper constructor pattern:
```rust
fn snap_clean() -> EnvSnapshot {
    EnvSnapshot {
        handoff_child: false, trampoline_guard: false,
        echo_commune: false, skip_resume: false, team_name: false,
    }
}
fn src_startup() -> SourceInput {
    SourceInput { source: Some("startup".to_string()), agent_type: None }
}

#[test]
fn fire_f1_vanilla_fresh_start() {
    assert!(should_emit_auto_pick(&snap_clean(), &src_startup(), false));
}

#[test]
fn no_fire_n5_psyche_wrapper_agent_type() {
    let mut src = src_startup();
    src.agent_type = Some("psyche".to_string());
    assert!(!should_emit_auto_pick(&snap_clean(), &src, false));
}
```

**Source-order assertion (Test G analog)** — `plugin_session_start.rs:864-887` already demonstrates the `include_str!` byte-scan pattern. Phase 33 should add a similar assertion pinning that AUTO-03 emission lives BETWEEN `inject_reorientation_if_needed` and `super::resume::run_with_input`:
```rust
#[test]
fn auto_pick_call_site_lives_between_reorientation_and_resume() {
    const SRC: &str = include_str!("plugin_session_start.rs");
    let reorient_idx = SRC.find("inject_reorientation_if_needed(&input, prior_session_id.as_deref())").unwrap();
    let auto_pick_idx = SRC.find("should_emit_auto_pick(&env, &src, ppid_has_perch)").unwrap();
    let resume_idx = SRC.find("super::resume::run_with_input(&input);").unwrap();
    assert!(reorient_idx < auto_pick_idx);
    assert!(auto_pick_idx < resume_idx);
}
```

---

### `tests/auto_pick_integration.rs` (new) — subprocess wire-emission tests

**Analog: `tests/plugin_session_start_refresh.rs` + `tests/plugin_session_start_psyche_context.rs`**

These existing test files demonstrate the subprocess-spawn pattern for SessionStart hook verification: spawn `target/<profile>/owl plugin-session-start` with stdin JSON, assert stdout for expected envelope shapes, and gate `SPT_TRAMPOLINE_GUARD=1` to bypass version-trampoline re-exec.

**Phase 33 application** (RESEARCH §3c, mitigated):
- For **rejection-path tests** (N5/N7/N10/etc), set `SPT_TRAMPOLINE_GUARD=1` because the trampoline gate also rejects (test side-effect is "both gates fire" — still correctly asserts no `<spt-live-auto-pick>` in stdout).
- For **positive-emission tests** (F1 happy path), do NOT set `SPT_TRAMPOLINE_GUARD=1`; rely on the from-cargo-target launch path skipping trampoline naturally (current_exe lives in `target/`, not in a versioned plugin cache dir — `parse_version` returns None at plugin_session_start.rs:493-495, which short-circuits the trampoline without rejecting AUTO-03).
- Assert stdout shape: `assert!(stdout.contains("<spt-live-auto-pick>"))` and `assert!(stdout.contains("\"hookEventName\":\"SessionStart\""))`.
- Assert NO stderr leakage on rejection paths: `assert!(stderr.is_empty() || !stderr.contains("auto-pick"))`.

---

### `tests/skill_hints.rs` — AUTO-08 argument-hint extension + AUTO-05/06 description guard

**Analog: existing `argument_hint_keys_known_set` test** — `tests/skill_hints.rs:147-199`

```rust
#[test]
fn argument_hint_keys_known_set() {
    let dir = skills_dir();
    let expected: &[(&str, &str)] = &[
        ("list-ready", "[--all] [--offline] [--here]"),
        ("list-live", "[--all] [--offline] [--here]"),
        ("list-psyche", "[--all] [--offline] [--here]"),
        ("commune", ""),
        ("psyche-download", "[<id>]"),
        ("whoami", ""),
    ];
    // ... iteration body assertion ...
}
```

**Phase 33 application (AUTO-08)**: append one entry to `expected`:
```rust
("live", "<id> [--period <seconds>] | [--auto]"),
```
No other changes to this test — the iteration body already handles N entries generically. The existing `argument_hint_values_quote_yaml_special_chars` test (skill_hints.rs:101-144) already enforces that the new value (containing `|`, `[`, `]`) is double-quoted; no extension needed.

**Analog for AUTO-05/06 description regression-guard (new test in same file)**:

The frontmatter parser `parse_frontmatter_keys` (skill_hints.rs:29-56) skips indented continuation lines, so it can't extract the multi-line `description:` body. New test must read the SKILL.md file directly and assert presence/absence of the eight accepted phrases and three rejected phrases:

```rust
#[test]
fn live_skill_description_contains_casual_language_triggers() {
    let path = skills_dir().join("live").join("SKILL.md");
    let content = std::fs::read_to_string(&path).unwrap();
    let accepted = [
        "continue live work", "resume live work",
        "continue live agent", "resume live agent",
        "live agent continue", "live agent resume",
        "live work continue", "live work resume",
    ];
    for phrase in &accepted {
        assert!(content.contains(phrase),
            "AUTO-05/06: description must list accepted casual trigger `{}`", phrase);
    }
    let rejected_mentions = ["keep going", "resume work", "continue"];
    // Each rejected phrase must appear in a "Does NOT route" context — assert
    // presence under a negative-example marker.
    for phrase in &rejected_mentions {
        assert!(content.contains(phrase),
            "AUTO-05/06: description must explicitly mention rejected phrase `{}`", phrase);
    }
    assert!(content.contains("Does NOT route") || content.contains("non-triggers"),
        "AUTO-05/06: description must contain a negative-example header for rejected phrases");
}
```

This follows the same self-contained read+assert pattern as `every_skill_has_argument_hint` (skill_hints.rs:71-99) — uses `skills_dir()` helper, reads file directly, asserts substring presence.

---

### `.planning/REQUIREMENTS.md` + `.planning/ROADMAP.md` — doc amendment commit

**Analog: Phase 31 D-11 / Phase 32 LIST-04 amendment commit pattern** (referenced in CONTEXT.md and RESEARCH.md §6).

**Pattern shape** (mirrors Phase 31 D-11 and Phase 32 LIST-04):
- Single atomic commit at the START of Plan 01 (BEFORE any code or skill edits).
- Two files staged: `.planning/REQUIREMENTS.md`, `.planning/ROADMAP.md`.
- Commit subject: `docs(33): strike FRESH-04/05; reword FRESH-06; revise ROADMAP SC#2 per D-03/D-04`.
- Body: brief rationale citing CONTEXT.md D-03 and D-04; reference Phase 29 echo-commune auto-fire as the natural-transition guarantee.

**REQUIREMENTS.md current state** (verified via grep):
- Line 52: `- [ ] **FRESH-04**: Single-fire sentinel ...` → **STRIKE**
- Line 53: `- [ ] **FRESH-05**: clear-psyche lineage ...` → **STRIKE**
- Line 54: `- [ ] **FRESH-06**: Fork ... suppress first-commune ...` → **REWORD** per RESEARCH §6.1
- Lines 140-142: Traceability table rows for FRESH-04, FRESH-05 → **STRIKE rows** (keep FRESH-06 row, update wording if needed)

The downstream `/gsd-verify-work 33` consumes the amended REQUIREMENTS.md and ROADMAP.md — that's why the amendment commit MUST land first (verifier resolves struck IDs as N/A, not Pending).

---

## Shared Patterns

### Pattern S1: Pure-predicate factoring for unit-testability

**Source files:** `src/owl/resume.rs:21-45` (`should_skip_resume_from_input`), `src/owl/plugin_session_start.rs:115-181` (`inject_reorientation_if_needed`'s internal `SourceInput` parse).

**Apply to:** AUTO-03 predicate (`should_emit_auto_pick`). Extract env-var reads into `EnvSnapshot::capture()` so the predicate body itself is pure — no `std::env::var` calls inside the predicate. This enables direct table-driven unit tests without env mutation (15+/17+ corpus per RESEARCH §3c).

Excerpt to mirror (resume.rs:11-17 + 21-45 shape):
```rust
#[derive(serde::Deserialize, Default)]
struct HookInput {
    #[serde(default)]
    source: Option<String>,
    #[serde(default)]
    agent_type: Option<String>,
}

fn should_skip_resume_from_input(input: &str) -> bool {
    // gate 1, gate 2, gate 3 — each returns true on rejection
    // falls through to `false` at end
}
```

Phase 33 inverts boolean polarity (returns `true` to fire) but keeps the same guard-clause structure.

### Pattern S2: hookSpecificOutput envelope (SessionStart additionalContext)

**Source:** `src/owl/resume.rs:415-424` (used by `inject_reorientation`).

**Apply to:** AUTO-03 `<spt-live-auto-pick>` emission. Single emission shape across the codebase. NEVER bare-XML `println!` — always wrap in the envelope JSON. Closes RESEARCH Assumption A1 / Pitfall 2 ambiguity.

```rust
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);
```

### Pattern S3: AskUserQuestion native-Other for free-text capture

**Source:** `plugin/spt/skills/live/SKILL.md:144` ("AskUserQuestion already provides a native free-text 'Other' input; an extra option duplicates it.")

**Apply to:**
- FRESH-03 first-commune prompt (single AskUserQuestion with options=["Proceed to init"], native Other captures additions per RESEARCH §1.3).
- AUTO-07 confirmation hop (single AskUserQuestion with options=["Resume {id}", "Pick a different agent"], native Other for free-text agent name per RESEARCH §2.3).

### Pattern S4: Skill description as dispatcher input

**Source:** All skills in `plugin/spt/skills/*/SKILL.md` frontmatter `description:` block.

**Apply to:** Phase 33 D-07/D-08 description rewrite of `plugin/spt/skills/live/SKILL.md`. List EXPLICIT phrases (8 accepted + 3 rejected as non-triggers). Description IS the dispatcher's matching corpus — no in-skill regex predicate. AUTO-07 confirmation is the safety net.

### Pattern S5: Doc-amendment-first plan structure

**Source:** Phase 31 D-11 (Plan 01 commit 1), Phase 32 LIST-04 (Plan 01 commit 1).

**Apply to:** Phase 33 Plan 01 commit 1 — REQUIREMENTS.md + ROADMAP.md amendment per RESEARCH §6 BEFORE any other code or skill edits. Cited by CONTEXT.md `canonical_refs` block.

### Pattern S6: Source-order pinning via `include_str!` byte-scan

**Source:** `src/owl/plugin_session_start.rs:864-887` (`snapshot_call_site_lives_between_write_and_refresh`), `:1001-1017` (`build_body_test_f_phase28_emit_before_phase29_dispatch`).

**Apply to:** AUTO-03 call-site ordering test — pin that emission lives BETWEEN `inject_reorientation_if_needed` and `super::resume::run_with_input` so a future refactor that reorders the chain triggers a test failure with a descriptive error. Cheap (no subprocess), zero-runtime-cost regression guard.

### Pattern S7: Silent rejection (no stderr leakage in subagent/wrapper contexts)

**Source:** `src/owl/resume.rs::should_skip_resume_from_input:21-45` (returns silently; no eprintln on rejection), `src/owl/plugin_session_start.rs::inject_reorientation_if_needed:115-148` (same).

**Apply to:** AUTO-03 predicate. RESEARCH §3b / §8.2 / AUTO-04: predicate returns false → ZERO stdout, ZERO stderr. No `eprintln!` anywhere in the rejected-emit path. Unit tests must assert empty stderr on rejection paths.

---

## No Analog Found

No files in Phase 33 lack a close analog. All six modified or new files map to existing patterns in the codebase. The closest "novel" surface is the `<spt-live-auto-pick>` XML tag itself, which has no prior emission site — but the envelope mechanism (Pattern S2) and the XML-as-additionalContext convention (resume.rs `<spacetime-reorientation>`, `<owl-active-perch>`, `<owl-auto-resume>`, `<psyche-context>`, `<owl_orphan_warning>`) are all established patterns.

---

## Metadata

**Analog search scope:**
- `plugin/spt/skills/**/SKILL.md` (17 skills enumerated)
- `src/owl/plugin_session_start.rs` (full file, 1080 lines)
- `src/owl/resume.rs` (lines 1-425 read; full hook output + reorientation surface covered)
- `src/live/context.rs` lines 425-452 (NO-CONTEXT emission)
- `src/live/pick_spec.rs` lines 140-200 (build_spec entry)
- `src/common/hook_output.rs` lines 115-160 (find_perch helpers)
- `tests/skill_hints.rs` (full file, 200 lines)
- `tests/` glob (existing subprocess integration test files identified for §3c integration tests)

**Files scanned:** 9 primary analogs + 17 skill frontmatter files enumerated.

**Pattern extraction date:** 2026-05-17
