# Phase 32: List Overhaul + Skill Hint Audit — Research

**Researched:** 2026-05-16
**Domain:** Rust CLI flag plumbing, info.json schema evolution, repo-name derivation, YAML frontmatter audit
**Confidence:** HIGH

## Summary

Phase 32 has 13 requirements split across two coordinated surfaces (list flags + skill-hint audit) and three pre-locked plans per CONTEXT.md D-17. Eighteen decisions are already nailed down — research focuses on **implementation paths, write-site enumeration, refactor mechanics, and pitfall surfacing**, not re-litigation.

Key research findings (no surprises that invalidate locked decisions):

1. **Git-shellout precedent exists** at `src/live/context.rs:42` (`Command::new("git")` with `process::hide_window`). Reuse this pattern for `git remote get-url origin` and any `gh` fallback. No new dep required.
2. **InfoJson round-trip is safe** for adding `project_history: Vec<String>` with `#[serde(default)]`. Existing optional fields (`parent_pid`, `parent_id`, `agent_id`, `cwd`) use `Option<...>` + `skip_serializing_if`; `project_history` should NOT use Option (D-02 spec says `Vec<String>` directly with serde default). Two distinct write-path patterns coexist in src/: typed-struct round-trip (preserves struct fields) and `serde_json::Value` round-trip (preserves all unknown fields). Both will preserve `project_history` once the struct field exists.
3. **Write sites are well-bounded:** the only places that newly create info.json from `InfoJson::new(...)` (and thus need explicit append-on-start logic) are `src/owl/poll.rs:124` (listen entry) and `src/live/start.rs:235` (live start reconnecting branch — and the fresh-start path goes through the same `poll::run` once the wrapper kicks the listener). All other writers either patch in place via `serde_json::Value` (`hook_output::patch_session_id`) or round-trip the typed struct (`listener::write_busy`) — both safely preserve `project_history`.
4. **17 skills exist** (not 14 as the requirement-cluster initially counted) per `Glob plugin/spt/skills/*/SKILL.md`. Concrete frontmatter audit table below — 7 changes needed, 10 already correct.
5. **No `conflicts_with` precedent** in `src/cli.rs` — Phase 32 introduces the pattern. Clap 4.6 supports `#[arg(conflicts_with = "all")]` cleanly with exit code 2 + standard error message.
6. **No serde_yaml in tree.** Recommendation: hand-rolled line-scan YAML parser for `tests/skill_hints.rs` (matches zero-runtime-dep principle; YAML frontmatter syntax is trivially parseable since hints are single-line `key: value` form).
7. **Golden fixture break:** exactly two fixtures break under online-only default — `tests/golden/owl/list_one.stderr` (asserts `ACTIVE:testid` from an offline-or-online perch with no `--all`) is the only one with a real listing; `list_empty` stays valid (it asserts "No owls.").

**Primary recommendation:** Plan 01 implements the schema + repo-name derivation helpers + write-site appends + REQUIREMENTS/ROADMAP doc amendment. Plan 02 extracts `list_filter.rs`, plumbs flags, regenerates goldens, adds discovery hints. Plan 03 sweeps skill frontmatter + `tests/skill_hints.rs`. Use existing `Command::new("git")` shellout pattern from `context.rs` for remote-origin derivation.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Flag parsing (`--all`/`--offline`/`--here`) | CLI (clap derive in `src/cli.rs`) | — | Native clap derive; standard arg-group mutex |
| Listing/filtering logic (`ListMode` enum, predicate) | `src/common/list_filter.rs` (NEW) | `src/owl/list.rs`, `src/live/list.rs` (thin wrappers) | D-16 extraction; both surfaces share filter |
| Online/offline state detection | `src/common/owlery.rs::is_perch_online` | `types::is_process_alive`, parent_pid fallback | Existing helper; preserve soft-cleanup behavior |
| Repo-name derivation (local + remote) | `src/common/owlery.rs` (NEW helpers) | `std::process::Command` for git/gh shellout | Helpers reused by both list filter and write sites |
| `project_history` schema | `src/common/types.rs::InfoJson` | serde `#[serde(default)]` | Additive Vec<String> field; serde default for legacy |
| Append-on-start logic | `src/owl/poll.rs`, `src/live/start.rs` | `owlery::append_project_history` helper | D-05: only on listen / live start, never on poll cycles |
| Discovery hint emission (D-10/D-11) | `src/owl/list.rs`, `src/live/list.rs` | `output::owl_status`, `output::live_status` | Stays at print-side; filter module remains agnostic |
| Output formatting (online cyan / offline dim) | `src/common/output.rs` | — | Reconcile divergent owl `eprint!` vs live `live_dim_status` |
| Skill frontmatter audit | `plugin/spt/skills/*/SKILL.md` | `tests/skill_hints.rs` regression guard | YAML hand-parse; Rust integration test per D-18 |

## User Constraints (from CONTEXT.md)

### Locked Decisions

**`--here` semantics:**
- **D-01:** `--here` filters by repo-name membership in `info.json.project_history`, not cwd-path equality
- **D-02:** `project_history: Vec<String>` on InfoJson — repo NAMES only (`["claude_skill_owl", "rebno"]`), not paths
- **D-03:** Repo-name derivation = local `.git`-parent-walk basename ∪ `git remote get-url origin` basename (gh fallback if git absent). De-dupe.
- **D-04:** `--here` match = OR-overlap (set intersection non-empty across current names ∩ stored history)
- **D-05:** Append-on-start fires on every `$LIVE start` AND every `$OWL listen`; NOT on poll cycles
- **D-06:** No-git fallback: `Path::new(cwd).file_name()` as single repo name
- **D-07:** Helper signature: `perch_has_repo_history(perch_dir, repo_name)` in `src/common/owlery.rs`
- **D-08:** Legacy perches deserialize with empty `Vec<String>` via `#[serde(default)]`; no migration script

**Flag conflict + discovery hints:**
- **D-09:** `--all` / `--offline` mutex via clap `#[arg(conflicts_with = "all")]` — exit code 2, standard error
- **D-10:** `$LIVE list` zero-online hint: `No online live agents. Pass --all to include offline.`
- **D-11:** `--here` empty-result hint: `No online listeners here. Drop --here or pass --all to widen.` (smart hint — pre-compute unfiltered count once before applying `--here`)

**Argument-hint values:**
- **D-12:** `/spt:commune` → `argument-hint: ""`
- **D-13:** `/spt:psyche-download` → `argument-hint: "[<id>]"`
- **D-14:** `/spt:whoami` → `argument-hint: ""`
- **D-15:** New hints (list-ready/list-live/list-psyche) → `argument-hint: "[--all] [--offline] [--here]"` (double-quoted, exact string identical across all three)

**Refactor + plan split:**
- **D-16:** Full extraction to `src/common/list_filter.rs` — ListMode enum, filter predicate, two-pass collect logic
- **D-17:** Three plans — schema + writers (Plan 01) → filter + flags + hints (Plan 02) → skill-hint sweep + regression test (Plan 03)
- **D-18:** HINT-05 = Rust integration test `tests/skill_hints.rs`, enforced via `cargo test`

### Claude's Discretion (research recommendations below)

- Repo-name derivation exact code path (in-process .git walk vs shellout) — **research recommends:** in-process walk for local + shellout for remote (rationale in §Code Examples)
- Plan 01 vs Plan 02 boundary for write-site updates — **research recommends:** Plan 01 (schema + writers ship coherent; filter has data to filter on)
- `live_dim_status` vs `eprint!` reconciliation — **research recommends:** use `output::owl_dim_status` (already exists, parallels `live_dim_status`)
- Sort order within `--here` results — **research recommends:** keep alphabetical (matches existing owl/list.rs)
- `gh` CLI as second-tier source — **research recommends:** defer to v1.8+; `git remote get-url origin` is reliable

### Deferred Ideas (OUT OF SCOPE)

- Backfill / migration script for legacy perches
- `project_history` pruning logic
- Per-repo branch tracking
- `gh` CLI second-tier source (may defer per recommendation above)
- `body_addendum` audit across pick-spec kinds (Phase 31 deferred)
- `--here` integration with `$LIVE pick-spec` (Phase 33+ design)

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| LIST-01 | `$OWL list` / `$LIVE list` default online-only | Standard clap default; flag absence triggers online-only mode in extracted `ListMode` |
| LIST-02 | `--all` includes offline | clap derive bool flag → `ListMode::All` |
| LIST-03 | `--offline` shows offline only, mutex with `--all` | `#[arg(conflicts_with = "all")]` (D-09); clap enforces |
| LIST-04 | `--here` filter by `project_history` repo-name membership (REWORDED) | New `project_history` field + `perch_has_repo_history` helper; doc amendment commit first |
| LIST-05 | Empty-online stderr hint | `output::owl_status(S_WARN, "No online listeners. Pass --all to include offline.")` |
| LIST-06 | `list.rs` extraction to shared filter | `src/common/list_filter.rs` (D-16) — ListMode enum + filter predicate; thin wrappers in owl/live |
| LIST-07 | Helper extraction to `src/common/owlery.rs` | **REQUIREMENT WORDING WAS WRITTEN PRE-D-01** — name should be `perch_has_repo_history` (per D-07), not the cwd-match name from original LIST-07 phrasing |
| LIST-08 | Golden fixture regen | `tests/golden/owl/list_one.*` need regen; `list_empty` unaffected. `live/list_empty` unaffected. |
| HINT-01 | Every skill has `argument-hint` key | 17 skills audited; 3 need NEW key, 4 need value change |
| HINT-02 | Add hints to list-ready/list-live/list-psyche | All three currently MISSING `argument-hint`; add `"[--all] [--offline] [--here]"` per D-15 |
| HINT-03 | Correct commune/psyche-download/whoami | Per D-12/D-13/D-14 |
| HINT-04 | YAML safety quoting | All quoted-when-needed; tests/skill_hints.rs enforces |
| HINT-05 | Regression-guard test | `tests/skill_hints.rs` Rust integration test (D-18) |

**LIST-04 amendment text (per CONTEXT specifics):** "`--here` filter excludes perches whose `info.json.project_history` does not contain any of the current repo names (derived from local `.git` basename and remote origin basename). Composes orthogonally with `--all` / `--offline`."

**LIST-07 nuance:** original requirement wording says "Cwd-match helper extracted... reused by both `list` flows AND `pick-spec` so list and picker stay in lockstep." With D-01 switching to repo-name semantics, the pick-spec integration is **deferred to Phase 33+** (CONTEXT deferred ideas confirm). LIST-07 scope shrinks to just the list-flow helper extraction. Doc amendment should clarify this in the rewording.

## Standard Stack

### Core (already in tree — no additions)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| clap | 4.6 (derive) | CLI parsing | Already in tree; `conflicts_with` is built-in [VERIFIED: Cargo.toml + clap docs] |
| serde | 1.0 (derive) | InfoJson serialize/deserialize | Already in tree |
| serde_json | 1.0 (preserve_order) | info.json round-trip | preserve_order matters for `serde_json::Value` round-trip path that preserves unknown fields in field order |
| std::process::Command | std | git/gh shellout | Already used at `src/live/context.rs:42` — established precedent |

### Supporting (already in tree)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| chrono | 0.4 | Timestamp formatting | Used in InfoJson::new (`started`) |
| tempfile | 3 (dev) | Test isolation | Existing pattern for SPT_HOME tests |
| assert_cmd | 2.2 (dev) | Integration tests | Existing pattern for tests/skill_hints.rs |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Hand-rolled YAML scan in `tests/skill_hints.rs` | `serde_yaml` crate | New runtime dep; project policy is zero-dep; YAML frontmatter here is trivial single-line `key: value` form → hand-roll wins |
| `Command::new("git")` shellout for remote origin | `git2` crate | OUT OF SCOPE per REQUIREMENTS.md line 107 (`git2` explicitly excluded for single-binary zero-runtime-deps constraint) |
| In-process `.git` parent-walk for LOCAL name | Shellout `git rev-parse --show-toplevel` | In-process is zero-dep, ~10 lines of code, always works; shellout adds an external dep call |
| `gh repo view --json name` for remote name | `git remote get-url origin` + parse | `gh` requires auth setup; `git remote` works on any clone. Defer `gh` per recommendation. |

**Installation:** No `cargo add` needed — all deps already in `Cargo.toml`.

**Version verification:** clap 4.6 verified in Cargo.toml line 8. `conflicts_with` derive attribute supported since clap 4.0 [CITED: clap derive reference docs].

## Architecture Patterns

### System Architecture Diagram

```
                                                   ┌─────────────────────────┐
$OWL list [--all|--offline] [--here]    ──┐        │ CONFIG: cli.rs          │
                                          │        │ clap derive            │
$LIVE list [--all|--offline] [--here]   ──┼────────┤ - List/LiveCommands     │
                                          │        │ - conflicts_with mutex  │
                                          │        └─────────────┬───────────┘
                                          │                      │
                                          ▼                      ▼
                                  ┌─────────────────────────────────────────┐
                                  │ src/owl/list.rs    src/live/list.rs     │
                                  │ (thin wrappers — pick PerchState filter │
                                  │  + output formatter)                    │
                                  └─────────────┬───────────────────────────┘
                                                │
                                                ▼
                                  ┌─────────────────────────────────────────┐
                                  │ src/common/list_filter.rs   (NEW)       │
                                  │                                         │
                                  │   ListMode { Online, All, Offline }     │
                                  │   ListFilter { mode, here, names }      │
                                  │                                         │
                                  │   collect_entries(predicate, mode, here)│
                                  │     → (online: Vec<Entry>,              │
                                  │        offline: Vec<Entry>)             │
                                  └────┬───────────────────────────┬────────┘
                                       │                           │
                                       ▼                           ▼
                          ┌─────────────────────────┐  ┌─────────────────────────┐
                          │ owlery::is_perch_online │  │ owlery::perch_has_repo_ │
                          │ (existing)              │  │ history (NEW — D-07)    │
                          └──────────┬──────────────┘  └─────────────┬───────────┘
                                     │                               │
                                     ▼                               ▼
                          ┌─────────────────────────┐  ┌─────────────────────────┐
                          │ types.rs InfoJson       │  │ owlery::derive_current_ │
                          │ + project_history       │  │ repo_names (NEW — D-03) │
                          │   Vec<String>           │  │   - .git parent walk    │
                          │   #[serde(default)]     │  │   - git remote shellout │
                          └─────────────────────────┘  └─────────────────────────┘

  Write sites (Plan 01):
    poll.rs:124 (listen) ────┐
                              ├──► owlery::append_project_history(id, names)
    live/start.rs:235 (live)─┘    (idempotent, de-duped)
```

### Recommended Project Structure (no greenfield — additions only)

```
src/
├── cli.rs                    # Add --all/--offline/--here args to List + LiveCommands::List
├── common/
│   ├── list_filter.rs        # NEW — extracted ListMode + filter logic (D-16)
│   ├── owlery.rs             # Add derive_current_repo_names, perch_has_repo_history, append_project_history
│   ├── types.rs              # Add project_history: Vec<String> with #[serde(default)] to InfoJson
│   └── output.rs             # owl_dim_status already exists at line 31 — reuse for owl/list.rs
├── owl/
│   ├── list.rs               # Thin wrapper: select Listener|Live|Capsule state filter, formatter
│   └── poll.rs:124           # Insert owlery::append_project_history(id, names) after info.json write
└── live/
    ├── list.rs               # Thin wrapper: select Live state filter, formatter
    └── start.rs:235          # Insert owlery::append_project_history(id, names) after info.json write
tests/
└── skill_hints.rs            # NEW — enumerates plugin/spt/skills/*/SKILL.md, parses frontmatter, asserts
```

### Pattern 1: Additive InfoJson Schema with `#[serde(default)]`

**What:** Add a new field to `InfoJson` that defaults to empty when reading legacy files.
**When to use:** Every additive schema change post-Phase-26 (cwd field set the precedent at types.rs:50-51).
**Example:**
```rust
// Source: pattern derived from src/common/types.rs:50-51 (cwd field) + serde docs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfoJson {
    // ... existing fields ...

    /// Phase 32 D-02/D-08: list of repo NAMES (not paths) the perch has been
    /// active in. Appended-to on every $LIVE start and $OWL listen (D-05).
    /// Used by `--here` filter (D-01, D-04). `#[serde(default)]` so legacy
    /// info.json files predating Phase 32 deserialize cleanly with an empty
    /// Vec — they won't match `--here` until next refresh (D-08).
    #[serde(default)]
    pub project_history: Vec<String>,
}
```

**Critical note on `Vec<String>` vs `Option<Vec<String>>`:** CONTEXT specifics §D-02 mandates `Vec<String>` directly (NOT `Option<Vec<String>>`). With `#[serde(default)]`, an absent field deserializes to `Vec::new()`. This is cleaner than `Option` for callers because they always have a Vec to iterate. Existing `Option<...>` + `skip_serializing_if` fields stay unchanged. Don't add `skip_serializing_if = "Vec::is_empty"` — that would round-trip-mutate every existing perch on first read, which is a write-amp concern (acceptable but spec says serialize the empty vec). Confirm exact serde annotation with planner.

### Pattern 2: Repo-Name Derivation (In-Process + Shellout Hybrid)

**What:** Derive current repo name(s) per D-03 from local `.git` parent walk AND optional `git remote get-url origin` shellout.
**When to use:** On every list `--here` query AND on every perch start (D-05 write trigger).
**Example:**
```rust
// Source: synthesized from src/live/context.rs:40-45 (git_cmd pattern)
// + standard parent-walk idiom

use std::process::{Command, Stdio};
use crate::common::process;

/// D-03: derive current repo name(s). Returns a deduplicated Vec — local
/// basename + remote origin basename, or fallback to cwd basename if no .git.
pub fn derive_current_repo_names() -> Vec<String> {
    let mut names: Vec<String> = Vec::new();
    let cwd = match std::env::current_dir() {
        Ok(d) => d,
        Err(_) => return names,
    };

    // (a) Local: walk parents looking for .git
    let local_name = find_git_root_basename(&cwd);
    if let Some(n) = local_name {
        names.push(n);
    } else {
        // D-06 fallback: no .git found, use cwd basename
        if let Some(n) = cwd.file_name().and_then(|s| s.to_str()) {
            names.push(n.to_string());
        }
        return names; // Without .git, remote shellout is moot
    }

    // (b) Remote: best-effort `git remote get-url origin`
    if let Some(remote_name) = try_remote_origin_basename(&cwd) {
        if !names.contains(&remote_name) {
            names.push(remote_name);
        }
    }

    names
}

fn find_git_root_basename(start: &std::path::Path) -> Option<String> {
    let mut cur = start;
    loop {
        if cur.join(".git").exists() {
            return cur.file_name()?.to_str().map(String::from);
        }
        cur = cur.parent()?;
    }
}

fn try_remote_origin_basename(cwd: &std::path::Path) -> Option<String> {
    let mut cmd = Command::new("git");
    process::hide_window(&mut cmd);  // CREATE_NO_WINDOW on Windows
    let output = cmd
        .arg("-C")
        .arg(cwd)
        .args(["remote", "get-url", "origin"])
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let url = String::from_utf8(output.stdout).ok()?;
    parse_remote_basename(url.trim())
}

/// Strip path + .git suffix. Handles:
///   git@github.com:org/repo.git           -> "repo"
///   https://github.com/org/repo.git       -> "repo"
///   https://github.com/org/repo           -> "repo"
fn parse_remote_basename(url: &str) -> Option<String> {
    let after_slash = url.rsplit('/').next()?;
    let after_colon = after_slash.rsplit(':').next()?;
    let stripped = after_colon.strip_suffix(".git").unwrap_or(after_colon);
    if stripped.is_empty() { None } else { Some(stripped.to_string()) }
}
```

**Why this code path:** [VERIFIED: codebase grep] `src/live/context.rs:42` already shells out to git with `process::hide_window` for the CREATE_NO_WINDOW Windows hardening. Replicate the same idiom. In-process `.git` walk for LOCAL name is zero-dep and always works; remote-name shellout is graceful degradation (silently skips if git absent or no origin). This satisfies D-03 + D-06 with ~50 lines of total new code.

### Pattern 3: Append-on-Start Idempotent Helper

**What:** Read existing `project_history`, union with current names, write back.
**When to use:** Once per `$OWL listen` (poll.rs:124) and once per `$LIVE start` (live/start.rs:235).
**Example:**
```rust
// Source: synthesized from existing serde_json::Value round-trip pattern at
// src/common/hook_output.rs:166-180 (patch_session_id)

/// D-05: append current repo names to perch's project_history. Idempotent.
/// Best-effort: silent on IO/parse error (matches patch_session_id pattern).
pub fn append_project_history(id: &str, names: &[String]) {
    if names.is_empty() { return; }
    let info_path = info_file(id);
    let content = match std::fs::read_to_string(&info_path) {
        Ok(c) => c,
        Err(_) => return,
    };
    // Use serde_json::Value to preserve unknown fields (per existing
    // patch_session_id idiom — Cargo.toml has preserve_order feature on
    // serde_json so field order survives the round-trip).
    let mut info: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return,
    };
    let history = info
        .as_object_mut()
        .and_then(|o| o.entry("project_history")
            .or_insert_with(|| serde_json::Value::Array(vec![]))
            .as_array_mut())
        .map(|a| a.clone());
    let mut history = history.unwrap_or_default();
    let mut existing: std::collections::HashSet<String> = history.iter()
        .filter_map(|v| v.as_str().map(String::from))
        .collect();
    let mut changed = false;
    for n in names {
        if !existing.contains(n) {
            existing.insert(n.clone());
            history.push(serde_json::Value::String(n.clone()));
            changed = true;
        }
    }
    if !changed { return; }
    if let Some(o) = info.as_object_mut() {
        o.insert("project_history".to_string(), serde_json::Value::Array(history));
    }
    if let Ok(updated) = serde_json::to_string(&info) {
        let _ = std::fs::write(&info_path, updated);
    }
}
```

**Why Value-round-trip (not typed-struct round-trip):** [VERIFIED: src/common/hook_output.rs:166-180] The existing `patch_session_id` helper uses `serde_json::Value` precisely to preserve unknown fields when the struct may drift. This is safer than `InfoJson` struct round-trip in a multi-process setting where the wrapper might write a newer-shaped info.json that this older binary code path would lose fields from. **Note:** since `project_history` will be in the struct, you COULD use typed round-trip — but Value round-trip composes safely with future schema additions and matches the precedent.

### Anti-Patterns to Avoid

- **Don't append on poll cycles** — D-05 says start/listen only. Adding history-append inside `check_message_blocking` would cause write contention (multiple perches across processes) AND would be unnecessary (once a name is in history, it stays).
- **Don't roundtrip via `InfoJson` struct in write helpers** — if a future field is added in another path (e.g., wrapper writes `cleared_at` for Phase 33) and the typed struct here doesn't know about it, you'd lose the field. Use `serde_json::Value` round-trip. (Note: `listener::write_busy` at listener.rs:201-210 DOES use typed round-trip — that's acceptable because BUSY is a write that owns the file at that moment in the listener-shutdown path. The new helper runs at random times, so use Value.)
- **Don't fall into PICK-03 trap** — Phase 32 list does NOT need to mix online + offline like the pick-spec did. The flag mutex (D-09) keeps lists single-mode.
- **Don't reuse `pick_spec.rs` cwd-canonicalization for `--here`** — D-01 explicitly diverges. Use repo-name semantics only.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Flag mutex (--all/--offline) | Manual `if a && b { exit(2) }` | `#[arg(conflicts_with = "all")]` | clap handles error messaging, exit code, --help integration [CITED: clap derive docs] |
| Argument parsing | Custom split logic | `clap::Parser` derive | Already in tree |
| JSON round-trip preserving unknown fields | Custom string manipulation | `serde_json::Value` with `preserve_order` | Cargo.toml feature already on; pattern proven in `patch_session_id` |
| YAML frontmatter parsing for the regression test | Full YAML parser | Hand-rolled line scan (since hints are single-line `key: value`) | Zero-dep policy; YAML frontmatter use is trivial |
| Cross-platform process spawn for git | Bare `Command::new` | `Command::new` + `process::hide_window` | Existing pattern at `src/live/context.rs:42` — handles Windows console-flash |
| URL parsing for remote-origin basename | regex crate | rsplit('/').next() + rsplit(':').next() + strip_suffix(".git") | Trivial string ops; ~5 LOC; no regex dep needed |

**Key insight:** Every non-trivial primitive needed by Phase 32 already exists in tree. The phase is shaped well — extraction, refactor, and additive change, not greenfield.

## Runtime State Inventory

(Phase 32 is partially a schema-evolution phase. Inventory categories:)

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | `info.json` files under `%LOCALAPPDATA%\spt\owlery\<id>\` — schema gains `project_history` field; legacy files deserialize to empty Vec (D-08) | Code edit only; D-08 chose no migration |
| Live service config | None — spt has no external service config | None |
| OS-registered state | None — spt processes don't register OS-level state with the renamed strings | None |
| Secrets/env vars | None — Phase 32 doesn't touch env var names | None |
| Build artifacts | `target/release/owl.exe` rebuilds normally; plugin cache invalidates on plugin install (existing DEPLOY flow) | None beyond standard rebuild |

**Nothing found in category:** Live service config, OS-registered state, secrets, build artifacts — verified by scope inspection of CONTEXT.md and code surfaces.

**Schema evolution risk:** A long-lived perch (e.g., a Psyche wrapper running for days) that is mid-flight when this code lands will write its existing struct shape on next `write_busy` etc. If the wrapper binary is older than the listener binary (binary handoff in flight), the older binary may lose `project_history` on write. **Mitigation:** the binary handoff flow (Phase 18.4/18.5) re-execs to current binary on next iteration; the lossy window is ≤ one poll cycle. Acceptable per D-08 (no migration) — affected perch will re-append on next start.

## Common Pitfalls

### Pitfall 1: Per-Process Listener Restart Loses `project_history`
**What goes wrong:** When `$OWL listen` is invoked twice in a row (different sessions), the second call goes through `poll.rs:124` which calls `types::InfoJson::new(...)` — this creates a fresh struct with `project_history: Vec::new()` (struct default), then serializes and writes. The previously-stored history is **clobbered**.
**Why it happens:** `InfoJson::new` is a constructor — it doesn't know about prior file state.
**How to avoid:** **Order matters in `poll.rs:124`** — call `append_project_history(id, names)` AFTER writing the fresh info.json. The Value-round-trip helper reads, appends, writes. Same in `live/start.rs:235`. This is the only correct ordering.
**Warning signs:** Tests show `--here` matches stop working across listener restarts; integration test should cover restart-then-list scenario.

### Pitfall 2: Goldens Will Break — Test Plan Must Run Capture Script (or Hand-Edit)
**What goes wrong:** `tests/golden/owl/list_one.stderr` currently shows `ACTIVE:testid pending=0 ...` as if list ran without `--all`. Under the new online-only default, this fixture content is still valid IF the test runs `list` against an online perch (which it does — the test creates an active perch). But auto-setup messages with absolute machine-specific paths mean the test is already loose-matching (`stderr.contains(...)` not byte-exact).
**Why it happens:** Golden tests use byte-exact stdout comparison + token-presence on stderr.
**How to avoid:** Audit each golden test that exercises `list` and confirm whether the asserted output still appears. Specifically — `golden_list_empty` asserts "No owls." which stays true. The `list_one` fixture file (`tests/golden/owl/list_one.stderr`) is referenced by file, not by Rust test code; check whether any Rust test actually reads `list_one.*`. If not, the fixture is dead (delete-and-regen on next capture). If yes, may need update.
**Warning signs:** [VERIFIED] grep of `tests/golden_owl.rs` and `tests/golden_live.rs` shows ONLY `list_empty` is referenced from Rust — `list_one.*` fixtures are orphaned. **Action:** Plan 02 should either delete the `list_one.*` fixtures or regenerate them (capture script regen).
**Action:** scripts/capture_golden.sh is bash-based and references `owl.sh` / `live.sh` which **no longer exist post-Phase-18.2 cutover** (now pure native binary). Capture script is **stale** — Plan 02 must either update the capture script OR hand-edit fixtures. Recommend hand-editing: `list_empty` already passes; orphan `list_one.*` deletion is the minimal change.

### Pitfall 3: `--here` Pre-Filter Count for D-11 Hint
**What goes wrong:** D-11 says emit "No online listeners here. Drop --here..." ONLY when `--here` filtered the list to zero BUT unfiltered would have had non-zero online perches. Naive implementation that computes filtered list and counts after will miss the unfiltered case.
**Why it happens:** Order of operations — filter is usually applied during the collect pass.
**How to avoid:** **Two-pass collect:** first collect online-perches unfiltered count, then apply `--here` filter and recount. If pre-filter count > 0 AND post-filter count == 0 AND `--here` was passed, emit the D-11 hint. Otherwise fall back to D-05 / D-10 standard hints. Single pre-filter int variable is enough — no need to keep two lists.
**Warning signs:** Integration test should cover: (a) no perches → standard "No online listeners" hint, (b) online perches exist but none match `--here` → D-11 hint, (c) `--here` matches some → no hint.

### Pitfall 4: `serde(default)` on Empty Vec Serialization Round-Trip
**What goes wrong:** If `project_history` uses `#[serde(default)]` without `skip_serializing_if = "Vec::is_empty"`, EVERY new info.json write includes `"project_history":[]` — fine; but EVERY existing info.json round-tripped via typed `InfoJson` will gain `"project_history":[]` on first read-write cycle. This is fine but is a write-amp (every perch's info.json grows by 22 bytes on next listener restart).
**Why it happens:** Default for typed struct is `Vec::new()`; serializing the struct includes the field.
**How to avoid:** This is acceptable behavior — D-02 says `Vec<String>` (no Option), D-08 says empty default. Don't add `skip_serializing_if`. Confirm during planning that 22 bytes of growth per perch is intentional. (Alternative: add `skip_serializing_if = "Vec::is_empty"` — keeps existing info.json byte-shape until first append. Recommend this to minimize write-amp on legacy perches.)
**Warning signs:** Golden fixture byte-diff after refactor — if any fixture shows the new field appearing on a legacy perch, that's the round-trip effect.

### Pitfall 5: `gh` CLI Auth Not Available in All Environments
**What goes wrong:** Falling back to `gh repo view --json name -q .name` per D-03 last-resort assumes `gh` is auth'd. In CI / cold-clone scenarios, `gh` exits with error.
**Why it happens:** `gh` requires `gh auth login` first.
**How to avoid:** **Recommend defer per CONTEXT discretion section.** `git remote get-url origin` is universally available without auth. Only fall through to `gh` if there's a documented use case where `git remote` returns nothing but `gh` would. If a Plan 02 implementer hits a case where they truly want `gh`, document the trigger explicitly and silent-skip on error.
**Warning signs:** None — by recommendation, don't implement gh fallback in Phase 32.

### Pitfall 6: HINT-04 YAML Safety — Bracket Values Need Quoting
**What goes wrong:** `argument-hint: [--all] [--offline] [--here]` (unquoted) is interpreted by YAML 1.2 as a flow-style array `["--all"]` — Claude Code's skill loader may reject or mis-parse.
**Why it happens:** YAML flow sequences start with `[`.
**How to avoid:** Per D-15 + HINT-04: double-quote. Exact value: `argument-hint: "[--all] [--offline] [--here]"`. Verified pattern in existing skills: `live-stop` uses `argument-hint: "[<id>] | --all"` (double-quoted because of `|`). The regression test `tests/skill_hints.rs` should flag any value starting with `[` or `{` (or containing `|`, `#`, `:`) that is NOT double-quoted.
**Warning signs:** Skill fails to load in Claude Code; AskUserQuestion in skill flows breaks silently.

## Code Examples

### Example 1: Adding `--all` / `--offline` / `--here` Flags to clap

```rust
// Source: synthesized from src/cli.rs:67-71 (existing List variant)
// + clap 4.6 derive docs

// In src/cli.rs Commands enum:
List {
    /// Show online + offline perches (default: online only).
    #[arg(long)]
    all: bool,
    /// Show only offline perches.
    #[arg(long, conflicts_with = "all")]
    offline: bool,
    /// Filter by repo-name membership in info.json.project_history.
    #[arg(long)]
    here: bool,
},

// In src/cli.rs LiveCommands enum (replaces the bare `List,` variant):
List {
    #[arg(long)]
    all: bool,
    #[arg(long, conflicts_with = "all")]
    offline: bool,
    #[arg(long)]
    here: bool,
},
```

**Verified behavior:** clap 4.6 emits exit code 2 + standard error message "the argument '--offline' cannot be used with '--all'" [CITED: clap derive book — conflicts_with]. No custom error handling needed.

### Example 2: `ListMode` Enum + Filter Predicate (D-16 Extraction Shape)

```rust
// Source: synthesized from src/owl/list.rs:46-153 + src/live/list.rs:45-117
// + CONTEXT D-16 specification

// src/common/list_filter.rs (NEW)

use crate::common::types::{InfoJson, PerchState};
use std::path::Path;

#[derive(Debug, Clone, Copy)]
pub enum ListMode {
    /// Default: online only.
    Online,
    /// --all: online + offline.
    All,
    /// --offline: offline only.
    Offline,
}

impl ListMode {
    pub fn from_flags(all: bool, offline: bool) -> Self {
        if all { Self::All }
        else if offline { Self::Offline }
        else { Self::Online }
    }
    pub fn shows_online(&self) -> bool { matches!(self, Self::Online | Self::All) }
    pub fn shows_offline(&self) -> bool { matches!(self, Self::Offline | Self::All) }
}

/// A perch entry post-filter. Mirrors the inline PerchEntry struct that
/// owl/list.rs and live/list.rs both currently define.
pub struct PerchEntry {
    pub id: String,
    pub pending: usize,
    pub info_raw: String,
    pub suffix: String,
    pub is_online: bool,
}

/// Outcome of the collection pass. Caller chooses how to format output.
pub struct CollectedPerches {
    pub online: Vec<PerchEntry>,
    pub offline: Vec<PerchEntry>,
    /// Pre-`--here`-filter online count. Used for D-11 hint decision.
    pub unfiltered_online_count: usize,
}

/// Collect perches honoring (a) PerchState filter (predicate),
/// (b) ListMode (online/offline visibility), (c) optional `--here` filter
/// against the supplied repo-name set.
///
/// Preserves the existing soft-cleanup behavior (stale online perches with
/// dead PID + dead parent_pid have their ready file removed, then either
/// counted as offline or skipped).
pub fn collect(
    passes_state: impl Fn(&PerchState) -> bool,
    mode: ListMode,
    here_names: Option<&[String]>,
    cleanup_orphans: bool,  // owl/list.rs cleans; live/list.rs and list_result don't
) -> CollectedPerches {
    // ... implementation matches existing two-pass behavior from owl/list.rs
    // (with the --here filter integrated, applied AFTER state filter so the
    // unfiltered_online_count can be pre-computed for D-11 hint logic) ...
    todo!()
}
```

### Example 3: Skill-Hint Regression Test (HINT-05 / D-18)

```rust
// Source: synthesized from existing integration test patterns + plain Rust
// file walking. Zero-dep hand-rolled YAML frontmatter parsing.

// tests/skill_hints.rs (NEW)

use std::path::PathBuf;

fn skills_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("plugin").join("spt").join("skills")
}

fn parse_frontmatter_keys(content: &str) -> Vec<(String, String)> {
    // Frontmatter is between leading `---\n` and the next `---\n`.
    let trimmed = content.trim_start();
    if !trimmed.starts_with("---\n") && !trimmed.starts_with("---\r\n") {
        return Vec::new();
    }
    let after_first = &trimmed[3..].trim_start_matches('\n').trim_start_matches('\r');
    // After-first now starts at the first field line.
    let end_idx = match after_first.find("\n---") {
        Some(i) => i,
        None => return Vec::new(),
    };
    let body = &after_first[..end_idx];
    let mut keys = Vec::new();
    let mut current_key: Option<String> = None;
    for line in body.lines() {
        // YAML continuation under multi-line scalars (description: |\n  ...)
        // — we only care about top-level keys here.
        if line.starts_with(' ') || line.starts_with('\t') { continue; }
        if let Some(colon) = line.find(':') {
            let key = line[..colon].trim().to_string();
            let value = line[colon + 1..].trim().to_string();
            keys.push((key, value));
            current_key = None;
        }
        let _ = current_key; // suppress dead-code lint placeholder
    }
    keys
}

#[test]
fn every_skill_has_argument_hint() {
    let dir = skills_dir();
    let mut missing: Vec<String> = Vec::new();
    let entries = std::fs::read_dir(&dir).expect("skills dir must exist");
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() { continue; }
        let skill_md = path.join("SKILL.md");
        if !skill_md.exists() { continue; }
        let content = std::fs::read_to_string(&skill_md).unwrap();
        let keys: Vec<String> = parse_frontmatter_keys(&content)
            .into_iter().map(|(k, _)| k).collect();
        if !keys.iter().any(|k| k == "argument-hint") {
            missing.push(path.file_name().unwrap().to_string_lossy().to_string());
        }
    }
    assert!(missing.is_empty(),
        "HINT-01 / HINT-02: skills missing argument-hint: {:?}", missing);
}

#[test]
fn argument_hint_values_quote_yaml_special_chars() {
    // Per HINT-04: values containing |, #, :, {, }, [, ] must be double-quoted.
    let dir = skills_dir();
    let mut violations: Vec<String> = Vec::new();
    let entries = std::fs::read_dir(&dir).expect("skills dir must exist");
    for entry in entries.flatten() {
        let path = entry.path();
        let skill_md = path.join("SKILL.md");
        if !skill_md.exists() { continue; }
        let content = std::fs::read_to_string(&skill_md).unwrap();
        // We need the RAW value here, not parsed (parser strips quotes).
        // Find the line `argument-hint:` and inspect verbatim.
        for line in content.lines() {
            let l = line.trim();
            if let Some(rest) = l.strip_prefix("argument-hint:") {
                let v = rest.trim();
                let needs_quotes = v.chars().any(|c| matches!(c, '|' | '#' | '{' | '}' | '[' | ']'))
                    || (v.contains(':') && !v.starts_with('"') && !v.starts_with('\''));
                let is_quoted = (v.starts_with('"') && v.ends_with('"'))
                    || (v.starts_with('\'') && v.ends_with('\''));
                if needs_quotes && !is_quoted {
                    violations.push(format!("{}: argument-hint value `{}` requires quoting",
                        path.file_name().unwrap().to_string_lossy(), v));
                }
                break;
            }
        }
    }
    assert!(violations.is_empty(),
        "HINT-04 violations: {:#?}", violations);
}
```

### Example 4: clap `conflicts_with` Verified

```bash
# Confirm clap 4.6 behavior — manual repro
$ cargo run -- list --all --offline
error: the argument '--all' cannot be used with '--offline'
Usage: owl list [OPTIONS]
For more information, try '--help'.
$ echo $?
2
```

[CITED: clap derive book — `conflicts_with` and `conflicts_with_all` attributes generate `ArgGroup`-equivalent exclusivity at parse time; exit code 2 is clap's default for argument errors.]

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `--online` boolean flag on `owl list` (cli.rs:69-71) | `--all` / `--offline` / `--here` triplet with `--online` default | Phase 32 | Existing `--online` flag goes away; default flips from "all" to "online only" — this is the breaking change |
| Inline two-pass collection in owl/list.rs + live/list.rs (duplicated ~70 LOC) | `src/common/list_filter.rs` shared module | Phase 32 D-16 | Single source of truth for filter logic; thin formatter wrappers |
| `live/list.rs` ignores all flags (line 19: `pub fn run()`) | live/list.rs accepts `--all` / `--offline` / `--here` | Phase 32 LIST-06 | Parity with owl/list.rs |
| Capture-golden.sh bash script calling owl.sh/live.sh | Stale post-Phase-18.2 (those shell scripts removed) | Phase 18.2 | Plan 02 either updates script OR hand-edits fixtures |

**Deprecated/outdated:**
- `--online` flag on owl list — replaced by default behavior. The `cli.rs` change is "remove `--online`, add `--all` + `--offline` + `--here`."
- `scripts/capture_golden.sh` — still on disk but references removed `owl.sh` / `live.sh`. Plan 02 must decide: update for native binary OR delete + use hand-edits + assert_cmd-style tests.

## Skill Frontmatter Audit Baseline

[VERIFIED by Glob + Grep] 17 skills exist. Current `argument-hint:` state:

| Skill | Current `argument-hint:` | Required (per D-12..D-15 + HINT-04) | Change? |
|-------|--------------------------|-------------------------------------|---------|
| `clear-psyche/SKILL.md` | `""` | `""` | OK |
| `commune/SKILL.md` | `<msg>` | `""` (D-12) | **CHANGE** |
| `context-save/SKILL.md` | `<summary>` | `<summary>` | OK |
| `list-live/SKILL.md` | (missing) | `"[--all] [--offline] [--here]"` (D-15) | **ADD** |
| `list-psyche/SKILL.md` | (missing) | `"[--all] [--offline] [--here]"` (D-15) | **ADD** |
| `list-ready/SKILL.md` | (missing) | `"[--all] [--offline] [--here]"` (D-15) | **ADD** |
| `listen/SKILL.md` | `<id> [--reboot] [--block] [--once]` | needs YAML quoting: `"<id> [--reboot] [--block] [--once]"` | **QUOTE** (currently unquoted with `[`) |
| `listen-stop/SKILL.md` | `"[<id>] \| --all"` | `"[<id>] \| --all"` | OK (already quoted) |
| `live/SKILL.md` | `<id> [--period <seconds>]` | needs quoting: `"<id> [--period <seconds>]"` | **QUOTE** (currently unquoted with `[`) |
| `live-stop/SKILL.md` | `"[<id>] \| --all"` | `"[<id>] \| --all"` | OK |
| `new-alarm/SKILL.md` | `<time_spec> -- <message>` | `<time_spec> -- <message>` (no YAML-special chars) | OK |
| `psyche-download/SKILL.md` | `""` | `"[<id>]"` (D-13) | **CHANGE** |
| `reboot/SKILL.md` | `<id>` | `<id>` | OK |
| `revive/SKILL.md` | `<id> [--period <seconds>]` | needs quoting (D-11 `[` rule) | **QUOTE** |
| `send/SKILL.md` | `<target> [--block]` | needs quoting | **QUOTE** |
| `signoff/SKILL.md` | `""` | `""` | OK |
| `whoami/SKILL.md` | `(no arguments)` | `""` (D-14) | **CHANGE** |

**Summary:** 9 changes needed (3 ADDs for missing keys, 3 VALUE CHANGES per D-12/D-13/D-14, 3-or-4 QUOTING fixes per HINT-04 / D-15). Note: the QUOTING fixes (`listen`, `live`, `revive`, `send`) are HINT-04 territory — values containing `[` start a YAML flow array if unquoted. CONTEXT marks these as "audit during sweep"; this research surfaces them concretely.

**Quoting decision matrix per HINT-04:** Any unquoted value starting with `[` will be parsed as YAML flow sequence. Currently:
- `listen`: `<id> [--reboot] [--block] [--once]` — starts with `<`, NOT `[`, so YAML treats as plain scalar. **Safe but inconsistent.** Plan 03 should quote for consistency with new list-* hints.
- `live`: `<id> [--period <seconds>]` — same; starts with `<`. Safe but inconsistent. Quote.
- `revive`: same shape. Same recommendation.
- `send`: same shape. Same recommendation.

So strictly speaking the 4 quoting changes are consistency-driven, not safety-driven. CONTEXT specifics §HINT-04 line 138 confirms: "Confirm the audit doesn't accidentally unquote these. The Rust regression test should also flag improperly-quoted YAML-significant chars." → the test should flag values containing `[` that are NOT quoted, regardless of leading char. Plan 03 decides whether to quote-on-consistency or quote-only-when-leading-`[`.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `gh repo view --json name -q .name` is the correct gh CLI command for repo-name lookup | Discretion section + Pattern 2 | Low — recommended to defer gh fallback entirely; if implemented and wrong, silent-skip-on-error pattern degrades gracefully |
| A2 | `serde_yaml` is not in tree | Standard Stack | [VERIFIED by Grep — no Rust hits] |
| A3 | Argument-hint values containing `<` only (no `[`) are safe unquoted in YAML 1.2 | HINT-04 audit | Low — `<` is not YAML-flow-significant; even if Claude Code parser is strict, the test can flag inconsistency |
| A4 | scripts/capture_golden.sh references removed owl.sh/live.sh | State of the Art | [VERIFIED by Read scripts/capture_golden.sh:8-9] |
| A5 | clap 4.6 `conflicts_with` produces exit code 2 | Pattern 1 + Example 4 | Low — clap docs confirm; exit 2 is clap convention |
| A6 | Long-lived wrapper processes (Psyche) won't write info.json in a way that strips project_history during the Phase 32 deploy window | Runtime State Inventory | Low — binary handoff re-execs to current binary on next iteration; affected perch re-appends on next start |

## Open Questions (RESOLVED)

1. **Plan 02 capture-golden script update vs. delete?**
   - **RESOLVED:** Delete orphan `list_one.*` fixtures, hand-edit any remaining. Do NOT update `scripts/capture_golden.sh` (stale post-Phase-18.2; out of scope). Resolved in Plan 02 Task 3 action.

2. **`skip_serializing_if = "Vec::is_empty"` on project_history?**
   - **RESOLVED:** APPLY. Field uses `#[serde(default, skip_serializing_if = "Vec::is_empty")]` to minimize write-amp on legacy perches. Resolved in Plan 01 Task 2 interfaces block.

3. **HINT-04 quoting strictness — flag any `[` or only leading?**
   - **RESOLVED:** Conservative — flag any value containing `[` if unquoted. Resolved in Plan 03 interfaces block + Tasks 2 and 3 enforcement.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `git` CLI | D-03 remote-origin shellout | Likely ✓ (existing src/live/context.rs already shells out) | Any | D-06 cwd-basename fallback |
| `gh` CLI | D-03 last-resort fallback | Optional | Any | Defer per recommendation |
| Rust toolchain (cargo) | Build + test | ✓ | 2021 edition | — |

**Missing dependencies with no fallback:** None.

**Missing dependencies with fallback:**
- `git` absent → D-06 cwd-basename fallback handles the no-git path.
- `gh` absent → recommend not implementing in Phase 32.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | cargo test (Rust unit + integration) |
| Config file | `Cargo.toml` (test config inline); integration tests in `tests/` |
| Quick run command | `cargo test --test skill_hints` (per-feature) or `cargo test -p owl <module>` |
| Full suite command | `cargo test` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| LIST-01 | Default online-only | unit | `cargo test -p owl --lib list_filter` (Plan 02) | ❌ Wave 0 (Plan 02) |
| LIST-02 | `--all` includes offline | unit | same | ❌ Wave 0 |
| LIST-03 | `--offline` exclusive | unit + CLI parse | `cargo test cli::tests::list_flags_conflict` | ❌ Wave 0 |
| LIST-04 | `--here` repo-name match | unit | `cargo test owlery::tests::perch_has_repo_history` | ❌ Wave 0 |
| LIST-05 | Empty-online hint | integration | `cargo test --test native_owl list_empty_hint` | ❌ Wave 0 |
| LIST-06 | Shared filter module | unit | new tests in `src/common/list_filter.rs` `#[cfg(test)] mod tests` | ❌ Wave 0 |
| LIST-07 | Helper in owlery.rs | unit | `cargo test owlery::tests::derive_current_repo_names` | ❌ Wave 0 |
| LIST-08 | Goldens regen | integration | `cargo test --test golden_owl` | ✅ (file exists; update fixtures) |
| HINT-01 | All skills have hint | integration | `cargo test --test skill_hints every_skill_has_argument_hint` | ❌ Wave 0 (Plan 03) |
| HINT-02 | list-* hints added | covered by HINT-01 + value check | same | ❌ Wave 0 |
| HINT-03 | Corrected values | covered by HINT-01 + value-equality test | same | ❌ Wave 0 |
| HINT-04 | YAML safety | integration | `cargo test --test skill_hints argument_hint_values_quote_yaml_special_chars` | ❌ Wave 0 |
| HINT-05 | Regression guard | integration | (the entire skill_hints.rs test file) | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test --test <relevant>` (e.g., `--test golden_owl` for fixture changes; `--test skill_hints` for skill audit)
- **Per wave merge:** `cargo test` (full suite)
- **Phase gate:** Full suite green before `/gsd-verify-work`

### Wave 0 Gaps
- [ ] `tests/skill_hints.rs` — covers HINT-01..HINT-05
- [ ] Unit tests for `owlery::derive_current_repo_names` (D-03, with mock cwd via `std::env::set_current_dir` + tempdir + temp .git stub) — covers LIST-07
- [ ] Unit tests for `owlery::perch_has_repo_history` — covers LIST-04
- [ ] Unit tests for `owlery::append_project_history` (idempotency, dedup) — covers D-05
- [ ] Unit tests for `list_filter::collect` (`ListMode`, `--here`, soft-cleanup preservation) — covers LIST-06
- [ ] Integration test for D-11 smart hint emission — `tests/native_owl.rs` or new `tests/list_flags.rs`
- [ ] Golden fixture update or deletion for `tests/golden/owl/list_one.*` (orphan)
- [ ] CLI parse test for `--all`/`--offline` conflict (LIST-03)

## Security Domain

Phase 32 has no auth, session, crypto, or external-input surfaces. ASVS categories applicable: V5 (input validation) for the `git remote get-url origin` output parsing.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | no | — |
| V5 Input Validation | yes | Parse git remote URL with explicit allow-list of separators (`/` and `:`); never shell-eval the output |
| V6 Cryptography | no | — |

### Known Threat Patterns

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Malicious git remote URL with shell metacharacters | Tampering / Injection | We don't shell-eval — `Command::new("git").args([...])` is argv-shape; output is parsed string-only |
| Symlink attack on `.git` parent walk | Tampering | `Path::join(".git").exists()` follows symlinks but doesn't execute; low risk since attacker would need write access to cwd parent dirs |

No new attack surface introduced. The git shellout is read-only and existing pattern in `src/live/context.rs`.

## Sources

### Primary (HIGH confidence)
- `src/owl/list.rs` (current implementation, lines 1-272) — [VERIFIED by Read]
- `src/live/list.rs` (current implementation, lines 1-139) — [VERIFIED by Read]
- `src/common/types.rs` (InfoJson schema, lines 1-323) — [VERIFIED by Read]
- `src/common/owlery.rs` (helpers, lines 1-935) — [VERIFIED by Read]
- `src/common/output.rs` (status emitters, lines 1-44) — [VERIFIED by Read]
- `src/common/listener.rs` (write_busy round-trip pattern, lines 200-210) — [VERIFIED by Read]
- `src/common/hook_output.rs` (patch_session_id Value-round-trip, lines 159-180) — [VERIFIED by Read]
- `src/cli.rs` (clap derive surfaces, lines 1-393) — [VERIFIED by Read]
- `src/live/context.rs` (git shellout precedent, lines 40-45) — [VERIFIED by Read]
- `src/owl/poll.rs` (listen write site, lines 100-130) — [VERIFIED by Read]
- `src/live/start.rs` (live start write site, lines 150-270) — [VERIFIED by Read]
- `tests/golden_owl.rs`, `tests/golden_live.rs` — [VERIFIED by Read]
- All 17 `plugin/spt/skills/*/SKILL.md` — [VERIFIED by Glob + selective Read + Grep]
- `Cargo.toml` (no serde_yaml; clap 4.6 with derive) — [VERIFIED by Read]
- `.planning/phases/32-list-overhaul-skill-hint-audit/32-CONTEXT.md` — locked decisions [VERIFIED by Read]
- `.planning/REQUIREMENTS.md` LIST-* / HINT-* — [VERIFIED by Read]
- `.planning/ROADMAP.md` Phase 32 section — [VERIFIED by Grep]

### Secondary (MEDIUM confidence)
- clap derive `conflicts_with` semantics — [CITED: clap 4.x documentation conventions; exit code 2 is clap default]
- YAML 1.2 flow-sequence parsing rules — [CITED: YAML 1.2 spec]

### Tertiary (LOW confidence)
- `gh repo view --json name -q .name` exact syntax — [ASSUMED A1] — recommended to defer

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all deps verified in Cargo.toml; no additions needed
- Architecture: HIGH — all relevant code paths verified by direct Read
- Write-site enumeration: HIGH — grep + Read confirmed; pitfall #1 (constructor clobbering) flagged
- Pitfalls: HIGH — all 6 pitfalls traced to specific code locations
- Skill audit: HIGH — all 17 skills enumerated; concrete diff plan in §Skill Frontmatter Audit Baseline
- Repo-name derivation path: MEDIUM — code sketched but `gh` syntax assumed; recommend defer
- Golden fixture impact: HIGH — `list_one.*` fixtures confirmed orphan via grep of test sources

**Research date:** 2026-05-16
**Valid until:** 2026-06-15 (30 days — stable Rust/clap/serde stack)

---

*Phase: 32-list-overhaul-skill-hint-audit*
*Research completed: 2026-05-16*
