# Phase 23: Commune & Signoff Project-Root + HEAD SHA Stamping - Research

**Researched:** 2026-05-19
**Domain:** Rust subprocess (`git`) + OS hostname capture; EVENT envelope schema extension; YAML front-matter prepend on a markdown writer; psyche-download surface composition
**Confidence:** HIGH — all touchpoints read end-to-end; every D-01..D-14 decision mapped to a concrete code change. Soft-timeout strategy for `git` is the one MEDIUM-confidence area (no existing pattern in the codebase — proposed approach justified below).

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Stamped field set:**
- **D-01:** Five fields stamped on every commune/signoff/echo payload: `machine`, `project`, `branch`, `head_sha`, `head_subject`.
- **D-02:** `project` is `basename(git rev-parse --show-toplevel)` when git CLI available + inside repo; else basename of cwd. Subdir invocations stamp the same project. Same repo cloned to different paths still matches.
- **D-03:** `machine` is OS hostname (`gethostname()` on Unix, `COMPUTERNAME` on Windows). No `$SPT_MACHINE` override this phase.
- **D-04:** `head_subject` is first line of HEAD commit message, capped at 72 chars with `…` ellipsis on overflow.

**Carrier shape per payload type:**
- **D-05:** EVENT-wrapped payloads carry the 5 fields as **inline attributes** on the EVENT tag — siblings to `timestamp`. Applies to `init_signoff` and `echo_commune`.
- **D-06:** Plain commune is **promoted** to a typed EVENT envelope: `<EVENT type="commune" timestamp="..." machine="..." project="..." branch="..." head_sha="..." head_subject="...">body</EVENT>`. Replaces the prose `COMMUNE (ts): body` form across `src/live/commune.rs::run` and `commune_result`. Clean cutover.
- **D-07:** Context-save markdown files (`{ctx_dir}/{self_id}.md`) and amend-signoff appended sections both gain a **YAML front-matter block** stamping the same five fields. Initial save prepends front-matter at top of file; amend-signoff section prepends front-matter inside its `## Post-Signoff Amendment (ts)` block.

**psyche-download surface:**
- **D-08:** Two structured blocks alongside existing memformat + context body:
  - `<psyche-stamp machine=... project=... branch=... head_sha=... head_subject=.../>` — most-recent stored stamp (from context file's front-matter).
  - `<current machine=... project=... branch=... head_sha=... head_subject=... commits_since="N" commits_unpulled="N"/>` — live values. `commits_since` = `git rev-list --count {stored_sha}..HEAD`. `commits_unpulled` = `git rev-list --count HEAD..@{upstream}` (or `0` when no upstream / no network). Both omit gracefully when not in a repo.
- **D-09:** When `current.project == stored.project` AND any of `branch`/`head_sha`/`machine` differ, instruct Self to ask via AskUserQuestion: *"This project has advanced since my involvement. Should I catch up?"* with multiSelect options `Observe new commits` / `Peek at peer contexts` / `Skip for now` / `Don't ask again`. `Peek at peer contexts` rendered ONLY when Phase 24/25 `tracked/` restructure has landed AND other live-agent contexts for this project are discoverable; until then hidden. `Don't ask again` writes a per-(self_id, project) suppression marker.
- **D-10:** When `current.project != stored.project` (cross-project resume), psyche-download is silent — fields surface in `<current/>` but no banner, no AskUserQuestion.

**Not-a-repo fallback:**
- **D-11:** When cwd is not inside git repo (or git absent / fails / times out), `head_sha`/`branch`/`head_subject` attrs are **omitted entirely**. `machine` and `project` (cwd basename) always present. Same rule for YAML front-matter.

**Git invocation on hot path:**
- **D-12:** Direct subprocess call per fire. No cache. Trade marginal CPU for correctness + simplicity.
- **D-13:** Each git subprocess gets a **500ms soft timeout**. Timeout / nonzero exit / git-not-found → treat as not-a-repo per D-11 + single-line rate-limited stderr warning. Never blocks commune/signoff delivery.
- **D-14:** echo_commune fires from wrapper inside `psyche_dir` (not Self's cwd). Must stamp **Self's project**, not psyche dir's name. Wrapper resolves Self's project from matching live perch's `info.json`. If lookup fails, fall back to psyche_dir basename.

### Claude's Discretion

- Exact shape of EVENT-attr escaping for `head_subject` strings containing `"`, `<`, `>`, `&` — use existing `event_attr_escape` in `src/owl/poll.rs`. No new escaper.
- File layout for per-(self_id, project) "don't ask again" suppression marker — planner decides (likely under `$SPT_HOME/suppressions/`).
- Whether stamp helper (`fn stamp() -> Stamp`) lives in `src/common/git.rs` (new) or extends `src/common/owlery.rs` — planner's call.
- Test coverage breakdown across unit/golden/integration — researcher + planner triangulate; golden fixtures will need refresh on Linux (Windows CI excludes goldens).

### Deferred Ideas (OUT OF SCOPE)

- `$SPT_MACHINE` env override for `machine` field.
- Computed `<drift>` block with files-changed / shortstat.
- `project_path` (full absolute path alongside basename).
- Cross-project AskUserQuestion opt-in surface.
- Per-process / mtime-based git caching.
- Phase 24/25 `tracked/` directory restructure — forward-compat awareness only.
</user_constraints>

## Summary

Phase 23 adds a single 5-field stamp (`machine`, `project`, `branch`, `head_sha`, `head_subject`) to every commune-shaped wire payload AND every persisted psyche-context markdown body. Mechanically there are three composition surfaces and one consumption surface:

1. **Composers** — `compose_init_signoff_payload`, `compose_echo_commune_payload`, the wrapper-side `compose_commune_payload`, plus a NEW direct-CLI commune composer (replacing the prose form in `src/live/commune.rs`). All four become parameterized over `&Stamp` and append attrs via the existing `event_attr_escape`.
2. **Persisters** — `run_save`/`context_save_result` (initial save) and `run_amend_signoff`/`amend_signoff_result` (post-signoff append) gain a YAML front-matter prepend producing `---\nmachine: ...\nproject: ...\n---\n` at the file head OR at the amendment-section head.
3. **Reader** — `download_payload` parses the YAML front-matter back out, emits the `<psyche-stamp/>` block from stored values, computes a live `<current/>` block via fresh git calls plus `git rev-list --count` for delta counts, and (in same-project drift case) appends instruction text directing Self to fire AskUserQuestion.
4. **Producer of the stamp itself** — a new `fn stamp() -> Stamp` pure-ish struct producer that wraps OS hostname + four git subprocess calls each bounded by 500ms soft timeout.

The one architecturally novel piece is the 500ms soft-timeout pattern. The codebase has zero existing examples of subprocess timeouts — every existing `git` call swallows errors via `.ok()` or `unwrap_or(false)` and trusts that git will return promptly. Phase 23 introduces the first timeout-bounded subprocess pattern in `src/`; the recommended implementation uses `std::process::Command::spawn` + a separate thread that issues `child.kill()` after 500ms with `process::hide_window` for Windows console-flash suppression (no external `wait-timeout` crate — CLAUDE.md "no runtime deps unless strongly justified").

**Primary recommendation:** Create `src/common/git.rs` housing `Stamp` (struct), `pub fn stamp() -> Stamp`, plus four private helpers (`git_project_basename`, `git_head_sha`, `git_branch`, `git_head_subject`) and one timeout helper (`run_git_with_timeout`). Composers and persisters import `crate::common::git::{Stamp, stamp}`. Existing `git_commit_context` in `src/live/context.rs` stays untouched (it has its own write-side semantics — generation tagging, init-on-first-write, no timeout because it must succeed to commit the context). This boundary keeps the stamp helper read-only on git state and isolates the timeout pattern in one file.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Stamp acquisition (hostname + git rev-parse) | `src/common/git.rs` (new module) | — | Pure side-effecting reader; isolates timeout pattern in one place; reusable by 4+ call sites without circular deps (live/, owl/, common/) |
| EVENT envelope composition (init_signoff, echo_commune, commune-CLI) | `src/live/commune.rs`, `src/live/signoff.rs`, `src/owl/echo_commune.rs` | `src/owl/poll.rs::event_attr_escape` (reused) | Each composer is already a pure formatter; gains `&Stamp` parameter, appends attrs |
| EVENT envelope composition (commune file-drop) | `src/live/wrapper/mod.rs::compose_commune_payload` | — | Phase 30 site; same `&Stamp` parameter add, identical attr emission |
| YAML front-matter prepend (initial save + amend section) | `src/live/context.rs::run_save` / `context_save_result` / `run_amend_signoff` / `amend_signoff_result` | New helper `fn format_yaml_frontmatter(&Stamp) -> String` | Markdown writer surface; no new file needed. Pure string assembly |
| `<psyche-stamp/>` block emission | `src/live/context.rs::download_payload` | YAML parser (new — see Pitfall 4) | Reads the front-matter prepended above; emits stored stamp |
| `<current/>` block emission + count deltas | `src/live/context.rs::download_payload` | `crate::common::git::stamp()` + new `commits_delta(stored_sha)` | Live read on download; same module as `<psyche-stamp/>` keeps both sides of the diff together |
| AskUserQuestion directive (same-project drift) | `src/live/context.rs::download_payload` | Plugin skill prose (`plugin/spt/skills/live/SKILL.md`) | Directive text is data emitted to stdout; SKILL.md teaches Claude to interpret it |
| `Don't ask again` suppression | `$SPT_HOME/suppressions/{self_id}__{project}.marker` (planner's call on path) | `download_payload` (read), Claude-via-skill (write through `$LIVE`) | New filesystem state; needs a write command surface — see Open Question 1 |
| Self-project lookup for echo_commune (D-14) | `src/live/wrapper/echo_fire.rs` (caller) | `src/common/owlery.rs::info_file(self_id)` parse `project_history[0]` or new `project` field | Wrapper currently passes only `self_id` + `psyche_id`; will need to thread the Self project through to `run_echo_commune` |
| Hostname capture | `src/common/git.rs::hostname()` | windows-sys / libc | Cross-platform via existing dep set (windows-sys already pinned, libc on Unix). No new dep |

## Standard Stack

### Core (existing, reused — NO NEW DEPS)

| Crate | Version (Cargo.toml) | Purpose | Why Standard |
|-------|---------------------|---------|--------------|
| `std::process::Command` | std | git subprocess + hostname (Unix `hostname` cmd fallback) | Already the only subprocess primitive used in this codebase [VERIFIED: src/common/owlery.rs:446, src/live/context.rs:60, src/owl/echo_commune.rs:152] |
| `chrono` | 0.4 (Cargo.toml:13) | Optional: timestamp formatting if a timeout warning records when it fired | Already across echo_commune.rs, context.rs |
| `serde_json` | 1.0 (Cargo.toml:11, `preserve_order` feature) | Parse `info.json` for Self project (D-14 fallback chain) | Used in Phase 32 `perch_has_repo_history` [VERIFIED: src/common/owlery.rs:492] |
| `libc` (unix only) | 0.2 (Cargo.toml:16) | `gethostname()` if shellout to `hostname` command is rejected | Already a target-cfg dep [VERIFIED: Cargo.toml:15-16] |
| `windows-sys` | 0.61 (Cargo.toml:19) | `GetComputerNameW` if env-var path is rejected | Already a target-cfg dep [VERIFIED: Cargo.toml:18-19] |

### Hostname strategy — pick ONE, document why

Three viable options, ordered by preference:

| Option | Approach | Pros | Cons | Recommended |
|--------|----------|------|------|-------------|
| **A. Env var** | Read `$COMPUTERNAME` on Windows, `$HOSTNAME` on Unix, fall back to `gethostname` shellout | Zero unsafe code; zero new deps; matches D-03's literal wording | `$HOSTNAME` is not exported by default on Linux (it's a bash builtin); needs fallback | Use as **first try**; shellout as fallback |
| **B. `Command::new("hostname")`** | Shellout to `hostname` binary | Universal; portable; no unsafe | Adds one subprocess per stamp() call; subject to same 500ms timeout pattern | Use as **fallback** after env var |
| **C. Native FFI (`libc::gethostname` / `windows_sys::GetComputerNameW`)** | Direct syscall | Zero subprocess overhead; canonical | Requires `unsafe`; ~30-40 lines of platform-cfg boilerplate | Skip — overhead of 1-2 ms per spawn does not justify unsafe surface, given commune cadence is human-scale |

**Recommended hostname chain:** `env::var("COMPUTERNAME")` (Windows) / `env::var("HOSTNAME")` (Unix) → if absent or empty, shellout to `hostname` with 500ms timeout → if that fails, return literal string `"unknown"` (still emitted; D-01 says `machine` is always present).

[ASSUMED] `$HOSTNAME` may be unset on some non-bash Linux logins (zsh sets it; sh-only `init`-style PIDs may not). The shellout fallback covers this without forcing FFI.

### Supporting (existing helpers — already battle-tested)

| Helper | Location | Purpose |
|--------|----------|---------|
| `event_attr_escape` | `src/owl/poll.rs:663` | Escape `<`, `>`, `&`, `"` for EVENT attr values. Reused for all 5 new attrs [VERIFIED: src/owl/poll.rs:663-668] |
| `event_body_escape` | `src/owl/poll.rs:648` | Body escape (adds `\n → <br>`). Unaffected by this phase but composers must keep using it for body region |
| `hide_window` | `src/common/process.rs:12-21` | Windows CREATE_NO_WINDOW flag; required on every git subprocess [VERIFIED: src/common/process.rs:9-21] |
| `format_timestamp` | `src/common/time.rs:8-17` | Existing `timestamp=` attr formatter (e.g. `2026-05-19 14:30:00 PDT`); no change [VERIFIED: src/common/time.rs:8] |
| `is_init_signoff_envelope` | `src/live/wrapper/mod.rs:148` | Wrapper-side predicate; unaffected by additive attrs but verify test [VERIFIED: src/live/wrapper/mod.rs:148-156] |
| `derive_current_repo_names` | `src/common/owlery.rs:403` | Phase 32 existing repo-name walker; provides a tested pattern for the `project` field [VERIFIED: src/common/owlery.rs:380-478] |

### No new external crates

Per CLAUDE.md ("no runtime deps unless strongly justified") and the existing Cargo.toml minimal-dep posture, this phase introduces **zero new crates**. Specifically:

- No `wait-timeout` crate — implement timeout via `child.wait_timeout`-equivalent using `Command::spawn` + `JoinHandle` (see Code Examples §1).
- No `hostname` crate — env var + shellout fallback covers all platforms.
- No `serde_yaml` crate — front-matter is a small fixed-key set; hand-roll write + read with a tiny line-loop parser (see Code Examples §2). The codebase already hand-rolls JSON parsing in places (`src/common/owlery.rs::perch_has_repo_history`).

## Package Legitimacy Audit

**Skipped per protocol** — this phase introduces zero new packages. All implementation reuses crates already pinned in `Cargo.toml` (`clap`, `rusqlite`, `serde`, `serde_json`, `ctrlc`, `chrono`, `libc`, `windows-sys`).

## Architecture Patterns

### System Architecture Diagram

```
                  ┌────────────────────────┐
                  │  Commune-shaped fire   │
                  │ (commune / signoff /   │
                  │  echo_commune /         │
                  │  context-save /         │
                  │  amend-signoff)         │
                  └──────────┬─────────────┘
                             │
                             ▼
                  ┌────────────────────────┐
                  │ stamp() -> Stamp        │  ◄── 500ms soft-timeout
                  │ machine, project,       │      per git call
                  │ branch, head_sha,       │
                  │ head_subject            │
                  └──────────┬─────────────┘
                             │
       ┌─────────────────────┼──────────────────────────┐
       │                     │                          │
       ▼                     ▼                          ▼
┌────────────┐      ┌─────────────────┐       ┌────────────────────┐
│  EVENT     │      │  YAML front-    │       │  echo_commune      │
│  composer  │      │  matter         │       │  composer (D-14    │
│  (attr     │      │  (context.md   │       │   needs Self        │
│  append)   │      │  initial save   │       │   project, NOT     │
│            │      │  + amendment    │       │   psyche_dir)      │
└─────┬──────┘      │  section)       │       └─────────┬──────────┘
      │             └────────┬────────┘                 │
      ▼                      ▼                          ▼
┌──────────────────────────────────────────────────────────────┐
│  TCP/spool wire OR {ctx_dir}/{self_id}.md on disk             │
└──────────────────────────────────────────────────────────────┘
                              │
                              │  (later, on resume)
                              ▼
                  ┌──────────────────────┐
                  │ download_payload()   │
                  │  reads YAML →        │
                  │  emits <psyche-      │
                  │  stamp/>             │
                  │  reads live git →    │
                  │  emits <current/>    │
                  │  + counts            │
                  │  same-project drift→ │
                  │  AskUserQuestion     │
                  │  directive text      │
                  └──────────────────────┘
```

### Recommended Project Structure

```
src/
├── common/
│   ├── git.rs              ◄── NEW: Stamp struct + stamp() + timeout pattern + hostname()
│   ├── owlery.rs           ◄── (existing) holds derive_current_repo_names — Phase 23 leaves alone
│   ├── process.rs          ◄── (existing) hide_window — reused
│   └── time.rs             ◄── (existing) format_timestamp — reused
├── live/
│   ├── commune.rs          ◄── EDIT: prose → EVENT envelope with 5 attrs (D-06)
│   ├── signoff.rs          ◄── EDIT: compose_init_signoff_payload takes &Stamp (D-05)
│   ├── context.rs          ◄── EDIT: run_save/amend_signoff prepend YAML (D-07); download_payload emits 2 blocks + drift directive (D-08/D-09/D-10)
│   └── wrapper/
│       ├── mod.rs          ◄── EDIT: compose_commune_payload takes &Stamp; new helper for echo_fire to resolve Self project (D-14)
│       └── echo_fire.rs    ◄── EDIT: pass Self project down to run_echo_commune
└── owl/
    ├── echo_commune.rs     ◄── EDIT: compose_echo_commune_payload takes &Stamp (D-05)
    └── poll.rs             ◄── (existing) event_attr_escape — reused
```

### Pattern 1: Pure composer + &Stamp parameter

The existing `compose_*_payload` functions are pure formatters tested without filesystem/subprocess (good — research D-19 in CONTEXT.md "code_context" calls this out explicitly). Phase 23 preserves that purity by:

1. Adding a `stamp: &Stamp` parameter to each composer.
2. Computing `stamp()` at the **call site** (the `run` or `run_echo_commune` function), NOT inside the composer.
3. Letting unit tests construct mock `Stamp` literals — no subprocess in unit tests.

**Example:**
```rust
// Pure composer — stays trivially testable
pub(crate) fn compose_init_signoff_payload(
    timestamp: &str,
    stamp: &Stamp,
    message: Option<&str>,
) -> String {
    let attrs = stamp.event_attrs(); // returns " machine=\"...\" project=\"...\" ..."
    let event_body = match message { /* same as today */ };
    format!(
        "<EVENT type=\"init_signoff\" timestamp=\"{}\"{}>{}</EVENT>",
        timestamp, attrs, event_body
    )
}
```

The `Stamp::event_attrs()` method returns a single concatenated attr string with **leading space**; this keeps the composer's `format!` line a one-substitution insert and centralizes attr ordering + escape policy in one place.

### Pattern 2: YAML front-matter as fixed-key block

Front-matter is `---` delimiter + N lines of `key: value` + `---` + body. Phase 23 uses a fixed key set (5 keys, all from `Stamp`), so the writer is a hand-rolled `format!` and the reader is a line-loop with `starts_with("---")` + `split_once(':')`. No `serde_yaml`. The pattern matches the project's existing hand-rolled JSON parsing posture in `src/common/owlery.rs::perch_has_repo_history`.

**Initial save** (file does not exist yet):
```
---
machine: <escaped>
project: <escaped>
branch: <escaped>     # optional — omitted if D-11
head_sha: <escaped>   # optional — omitted if D-11
head_subject: <escaped>  # optional — omitted if D-11
---
{body}
```

**Amend-signoff** (section appended INSIDE existing file):
```
{existing trimmed}

## Post-Signoff Amendment ({timestamp})
---
machine: ...
project: ...
---
{amendment body}
```

The amendment's front-matter goes INSIDE the section heading, between heading and body. Existing `download_payload` reads the whole file as one string and emits it raw downstream of the memformat block — so a markdown-aware reader will treat the section's internal `---` as a horizontal rule, not a true YAML block. That is acceptable since `download_payload` itself parses the FIRST front-matter at file head for the `<psyche-stamp/>` block (the initial save's front-matter). The amendment's front-matter is forensics — it's there for hand-debugging "when did this amendment happen and from what state?" and is not parsed back out by Phase 23. (Phase 25 may iterate.)

### Pattern 3: Subprocess timeout via spawn + monitor thread

The codebase has no existing timeout pattern (verified via Grep on `timeout|Duration::from_millis|wait_timeout` — zero matches). The recommended pattern uses only std:

```rust
// In src/common/git.rs
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;

pub(crate) fn run_git_with_timeout(args: &[&str], cwd: &std::path::Path) -> Option<String> {
    let mut cmd = Command::new("git");
    crate::common::process::hide_window(&mut cmd);
    let mut child = cmd
        .arg("-C").arg(cwd)
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .stdin(Stdio::null())
        .spawn()
        .ok()?;

    // Monitor thread: kill after 500ms if still running.
    let pid = child.id();
    let (tx, rx) = mpsc::channel::<()>();
    let killer = std::thread::spawn(move || {
        if rx.recv_timeout(Duration::from_millis(500)).is_err() {
            crate::common::process::force_kill_process(pid);
        }
    });

    let output = child.wait_with_output().ok();
    let _ = tx.send(()); // cancel killer if we beat the timeout
    let _ = killer.join();

    let out = output?;
    if !out.status.success() {
        return None;
    }
    String::from_utf8(out.stdout).ok().map(|s| s.trim().to_string())
}
```

**Why this shape:**
- Uses existing `force_kill_process` (already cross-platform — TerminateProcess on Windows, SIGKILL on Unix) [VERIFIED: src/common/process.rs:107-130].
- One monitor thread per call (cheap; commune cadence is human-scale, not high-frequency). No thread-pool needed.
- `recv_timeout` is the cleanest std-only timeout primitive.
- Returns `Option<String>` because the only consumer behavior on timeout is "treat as not-in-repo" per D-11/D-13.

[ASSUMED] On Windows, `force_kill_process` requires `OpenProcess(PROCESS_TERMINATE)` to succeed against a process the current user owns — which is always true for a child process we just spawned, so this is safe.

### Pattern 4: Per-fire warning rate-limit (D-13 "rate-limited; not per-fire")

Don't add a global mutex. Use a process-local atomic-bool guard:

```rust
use std::sync::atomic::{AtomicBool, Ordering};
static WARNED_GIT_UNAVAILABLE: AtomicBool = AtomicBool::new(false);

// On first timeout / not-found:
if !WARNED_GIT_UNAVAILABLE.swap(true, Ordering::Relaxed) {
    eprintln!("WARNING: git unavailable or timed out (>500ms); stamping with machine+project only");
}
```

Once-per-process is the simplest interpretation of "rate-limited" and matches the codebase's `git_commit_context` warning style (one owl message on first failure, none afterward).

### Anti-Patterns to Avoid

- **Computing `stamp()` inside a composer** — breaks composer purity, contaminates unit tests with subprocess calls.
- **Caching stamp() across calls** — explicitly forbidden by D-12. Each fire is independent.
- **Using `serde_yaml`** — overkill for a fixed 5-key block; introduces a new runtime dep contrary to CLAUDE.md.
- **Re-encoding through `serde_json::Value` for the `<psyche-stamp/>` block** — these are XML-attr-shaped, not JSON. Use the same `format!` + `event_attr_escape` pattern as the EVENT composers.
- **Throwing a panic when git is absent** — D-13 is explicit: never blocks delivery. All git-call wrappers return `Option<String>`.
- **Emitting `head_subject` with raw `"` or `&`** — composer MUST run head_subject through `event_attr_escape` AFTER the 72-char ellipsis cap (count by char or byte? — see Open Question 3).

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| XML attr escaping | New escaper | `crate::owl::poll::event_attr_escape` [VERIFIED: src/owl/poll.rs:663] | Already `pub(crate)`, already tested, already amp-first (Pitfall 3 in `event_body_escape`). New escaper would invariably miss the amp-first ordering invariant |
| Windows console hide | `#[cfg(windows)] CommandExt::creation_flags(...)` ad-hoc | `crate::common::process::hide_window` [VERIFIED: src/common/process.rs:12] | Already cross-platform, already used by every other git callsite in the repo |
| Process kill | Direct `kill -9` shellout or `TerminateProcess` FFI | `crate::common::process::force_kill_process` [VERIFIED: src/common/process.rs:107] | Already cross-platform, already used by terminate_process_tree |
| Repo-name discovery | New parent-walker | `crate::common::owlery::find_git_root_basename` + `derive_current_repo_names` patterns [VERIFIED: src/common/owlery.rs:432, 403] | Phase 32 already solved this; same `.git` parent walk; lift the helper or extract a sibling |
| Timestamp formatting | `chrono::Local::now().format(...)` per call site | `crate::common::time::format_timestamp` [VERIFIED: src/common/time.rs:8] | Used by all 3 existing EVENT composers — uniform `2026-05-19 14:30:00 PDT` shape |
| Forward-slash path normalization | `.replace('\\', '/')` per call | `crate::common::owlery::to_forward_slash` [VERIFIED: src/common/owlery.rs:153] | Already UNC-aware |

**Key insight:** This phase touches 7 files but adds essentially ONE new piece of machinery (the Stamp). Every other transformation is `format!` glue + existing helpers. Resist the temptation to build a `YamlFrontmatter` abstraction or a `GitTimeout` crate-style API. The 5-key fixed schema does not earn it.

## Runtime State Inventory

> Phase 23 is a **metadata addition** phase — it does NOT rename, refactor, or move existing state. New state is purely additive (front-matter at top of context files; new attrs on wire envelopes; new `<psyche-stamp/>` + `<current/>` blocks on download). No existing on-disk values are modified by Phase 23 code paths.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | **None** — verified by reading `src/live/context.rs::run_save` (overwrites whole file) and `src/live/context.rs::run_amend_signoff` (appends to existing file). Existing context files without front-matter will simply have `download_payload` find no `<psyche-stamp/>` and emit only `<current/>`. Graceful degrade by construction. | None — backward compat is "absent stamp block" not "broken parse" |
| Live service config | **One new state** — per-(self_id, project) "Don't ask again" suppression marker (D-09). Lives under `$SPT_HOME/suppressions/` per Claude's Discretion guidance. Not in git; not exported. | Planner picks file naming + write path. Phase 24 forward-compat (see Pitfall 6) |
| OS-registered state | **None** — no scheduled tasks, no pm2 entries, no systemd units involved | None |
| Secrets/env vars | **None** — no new SOPS keys, no new auth tokens. `$COMPUTERNAME`/`$HOSTNAME` are read-only public env vars | None |
| Build artifacts | **None** — no compiled artifacts carry phase identity. Cargo rebuilds with new `src/common/git.rs` produce a new `owl.exe`, but no stale .egg-info / lock-file equivalent | None |

**Backward-compat with pre-Phase-23 context files:** A context.md written before Phase 23 has no `---\nmachine:...\n---\n` header. `download_payload` will detect this (front-matter parser returns `None`), and emit ONLY the `<current/>` block (no `<psyche-stamp/>`). Same-project drift detection is skipped (no stored project to compare against). This is the correct degrade — the upgrade path is the next commune-or-amend that overwrites/appends.

**Forward-compat with Phase 24/25 tracked/ restructure:** The suppression marker location matters here. See Pitfall 6 + Open Question 1.

## Common Pitfalls

### Pitfall 1: Subprocess timeout that leaks zombie children
**What goes wrong:** Naive `thread::sleep(500ms) then check` patterns leave child processes consuming CPU/FDs after the parent moves on. On Windows, the child PID gets recycled and a future `force_kill_process` could kill the wrong process.
**Why it happens:** No explicit `child.wait()` after `kill()` — the child handle stays open as a "zombie" reference until the parent process exits.
**How to avoid:** In `run_git_with_timeout`, after the killer fires, the main thread MUST still call `child.wait_with_output()` (or equivalent) to reap the zombie. The pattern in Pattern 3 above does this correctly because `wait_with_output()` is the FIRST thing the main thread does — the killer thread races with it.
**Warning signs:** On Windows, Task Manager showing growing `git.exe` count after repeated commune fires. On Unix, `ps -eo stat | grep Z`.

### Pitfall 2: head_subject containing literal `\n` after newline-collapse
**What goes wrong:** `git log -1 --pretty=%s` returns the first line of the commit message — but if a commit subject was authored with embedded `\n` literal (rare but possible in `--allow-empty-message` or programmatic commits), the raw stdout could contain a stray newline before the trim. That stray newline survives the 72-char cap and then becomes an unescaped `\n` inside an EVENT attr.
**Why it happens:** `event_attr_escape` does NOT convert `\n` to `<br>` (D-04 + `src/owl/poll.rs:656-657` note: "attribute values never carry newlines in this protocol"). A leaked newline in an attr breaks the parser.
**How to avoid:** Apply `.trim()` and `.lines().next().unwrap_or("")` after the git call, BEFORE the 72-char cap, BEFORE the escape. Three-line idiom; testable.
**Warning signs:** A commune from a commit with unusual subject crashes wrapper-side parsers; receivers see truncated attrs.

### Pitfall 3: 72-char cap counted in bytes when subject is multibyte UTF-8
**What goes wrong:** `&str[..72]` panics on non-char-boundary slicing if the subject contains emoji or multi-byte CJK. `git log` returns UTF-8 and commit subjects very commonly contain emoji (especially `feat:` or `fix:` prefixes).
**Why it happens:** Rust's byte-indexed slicing on `&str` is panic-on-non-boundary.
**How to avoid:** Use `subject.chars().take(72).collect::<String>()` and check if it differs from the original; if yes, append `…` (U+2026, three bytes). The 72-char cap in D-04 should be interpreted as char count (graphemes are overkill; chars match git's own line-counting).
**Warning signs:** Commune fires with an emoji-bearing subject cause a panic, caught by `catch_unwind` in commune.rs, silently drop the delivery.

### Pitfall 4: YAML front-matter parser confused by `---` in body content
**What goes wrong:** A user writes a context save body that contains a literal `---` line (markdown horizontal rule). On download, the parser walks past the first `---` (opening), reads keys (none, because the next line is body content), then a downstream `---` looks like the closing fence. Result: body content gets parsed as keys, garbage in `<psyche-stamp/>`.
**Why it happens:** Hand-rolled YAML parsers don't distinguish "fence at file start" from "fence anywhere".
**How to avoid:** Parser MUST require: (a) file starts with `---\n` (no leading whitespace), (b) parse only `^[a-z_]+: ` lines, (c) abort with "no front-matter" on first non-matching line, (d) require closing `---\n` within N lines (e.g. N=10 — there are 5 keys max). If any check fails, return `None` and the `<psyche-stamp/>` block is omitted (degrade gracefully).
**Warning signs:** Random body content showing up as `machine=` attr; psyche-stamp block looks corrupted.

### Pitfall 5: Wrapper-resolved Self project (D-14) when info.json has no project field yet
**What goes wrong:** `compose_echo_commune_payload` is called from the wrapper's `process_file_drop` AND from `run_echo_commune` (via `dispatch_commune_markers`). Both fire from `psyche_dir` cwd. The wrapper today does NOT have a baked-in record of Self's project — it has `self_id` only. Phase 24.1 adds a `last_project_name` to tracked agents/info.json, but Phase 23 must work BEFORE Phase 24 ships.
**Why it happens:** D-14 says "lookup via $OWL list / perch info.json" but the perch's info.json schema today carries only `owl_id, pid, parent_pid, mode, session_id` plus optional `live/psyche/spine/touch` flags — no project field.
**How to avoid:** Phase 23 must EITHER (a) extend perch info.json schema with a `project` field on perch creation (read from `derive_current_repo_names()[0]` at start time), OR (b) accept that echo_commune stamps `psyche_dir` basename as the project until Phase 24.1 lands. Option (a) is cleaner but expands scope. Option (b) is explicitly the D-14 fallback ("If lookup fails, fall back to psyche_dir basename"). **Recommendation:** Option (b) for v1.8 Phase 23 to stay scoped; planner should flag Phase 24.1 as the canonical resolution.
**Warning signs:** Echo-commune fires stamp `project="tracked"` (psyche_dir basename) instead of `project="claude_skill_owl"`; psyche-download drift detection misclassifies these as cross-project (D-10) and stays silent when it should ask.

### Pitfall 6: Suppression marker placement conflicts with Phase 24 tracked/ migration
**What goes wrong:** If Phase 23 plants the "Don't ask again" markers at `$SPT_HOME/suppressions/{self_id}__{project}.marker`, that's siblings to `owlery/` and `psyches/`. Phase 24's `tracked/` restructure will move per-agent state under `psyches/tracked/agents/{agent_id}/`, and Phase 24's migration logic must either (a) migrate suppressions in, or (b) accept that pre-Phase-24 suppressions live at the old top-level path and stay there forever (split brain).
**Why it happens:** Phase 23 and Phase 24 are independent in code but coupled in filesystem layout.
**How to avoid:** Plant suppressions at `$SPT_HOME/suppressions/{self_id}__{project}.marker` for Phase 23 (D-09 says "coordinate location"). When Phase 24 ships, it migrates these into `psyches/tracked/agents/{agent_id}/suppressions/` or equivalent. **Locked in CONTEXT.md Claude's Discretion** — planner sets the v1.8 path, Phase 24 owns the migration.
**Warning signs:** Phase 24 migrate step has to grep all of `$SPT_HOME` for stray Phase 23 state.

### Pitfall 7: `download_payload` reads `<psyche-stamp/>` from front-matter but the file already has Pending Commune / Pending Signoff sections (Phase 30) appended at the END
**What goes wrong:** Phase 30 added `append_pending_sections` which appends `## Pending Commune (written ...)` and `## Pending Signoff (written ...)` to the existing context file on download. Phase 23's front-matter is at the TOP. The two don't collide on disk, but `<psyche-stamp/>` must be emitted BEFORE the file body — and `download_payload` emits memformat THEN file body THEN pending sections (verified in `download_payload` source). So the stamp block must be inserted at the same position as the memformat block (before the body) or as a sibling alongside it.
**Why it happens:** Reading the layout: memformat → context.md raw → pending sections. The stamp block belongs with memformat (header metadata), not interleaved with body.
**How to avoid:** Emit `<psyche-stamp/>` and `<current/>` blocks AFTER the `<memformat>...</memformat>` block but BEFORE the raw context body. The `download_payload` function adds two new lines around line 244 (between memformat append and context.md read) for `<psyche-stamp/>` (parsed from the file's front-matter via a peek-read before the full body read) and `<current/>` (computed live). Then the file body raw read MUST strip its own front-matter so the body region of the download payload is clean prose — front-matter shouldn't surface twice (once parsed, once raw).
**Warning signs:** Download payload shows the YAML `---\n` fences in human-readable output as well as in the structured block. Confusing for readers; possibly miscategorized by AskUserQuestion synthesis.

### Pitfall 8: `is_init_signoff_envelope` predicate drift when init_signoff gains new attrs
**What goes wrong:** The predicate at `src/live/wrapper/mod.rs:148` matches lowercased `<event type="init_signoff"` exactly. The new attrs come AFTER `timestamp=` so the predicate continues to match. But the test fixtures at `signoff.rs:135` assert `payload.starts_with("<EVENT type=\"init_signoff\" timestamp=\"")` — that prefix still holds. **Verified: additive attrs do not break either.**
**Why it happens:** The predicate is a substring match, not a full schema check.
**How to avoid:** No action needed for the predicate. Update the test fixtures (`signoff.rs:131-160`) to assert that the new 5 attrs appear in the payload, AND keep the `starts_with("<EVENT type=\"init_signoff\" timestamp=\"")` assertion so we lock the attr-order contract (`type` first, then `timestamp`, then stamp attrs).
**Warning signs:** Wrapper case-insensitive predicate fails on a future refactor that moves `type=` to second position.

## Code Examples

### Example 1: Stamp helper module skeleton

```rust
// src/common/git.rs
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::time::Duration;

/// Phase 23 stamp — 5 fields appended to every commune-shaped payload
/// AND every persisted psyche-context. `branch`/`head_sha`/`head_subject`
/// are None when cwd is not in a git repo OR when any git call timed
/// out (D-11/D-13). `machine` and `project` always present.
#[derive(Debug, Clone)]
pub struct Stamp {
    pub machine: String,
    pub project: String,
    pub branch: Option<String>,
    pub head_sha: Option<String>,
    pub head_subject: Option<String>,
}

static WARNED_GIT_UNAVAILABLE: AtomicBool = AtomicBool::new(false);

/// Capture current stamp from cwd. Pure side-effect reader (git subprocess
/// + env var). Caller is responsible for not caching — each fire MUST call
/// stamp() fresh per D-12.
pub fn stamp() -> Stamp {
    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
    let machine = hostname();
    let project = git_project_basename(&cwd)
        .unwrap_or_else(|| cwd.file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown")
            .to_string());
    let head_sha = run_git_with_timeout(&["rev-parse", "HEAD"], &cwd);
    let branch = run_git_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], &cwd);
    let head_subject = run_git_with_timeout(&["log", "-1", "--pretty=%s"], &cwd)
        .map(|s| cap_subject_72(&s));

    if head_sha.is_none() && !WARNED_GIT_UNAVAILABLE.swap(true, Ordering::Relaxed) {
        eprintln!("WARNING: git unavailable / timed out (>500ms); stamping machine+project only");
    }

    Stamp { machine, project, branch, head_sha, head_subject }
}

impl Stamp {
    /// Render the 5 attrs (each optional) as a leading-space attr string
    /// suitable for splice into an EVENT opening tag. Skipped attrs are
    /// omitted entirely (D-11), not empty-string-set.
    pub fn event_attrs(&self) -> String {
        let mut out = String::new();
        out.push_str(&format!(" machine=\"{}\"",
            crate::owl::poll::event_attr_escape(&self.machine)));
        out.push_str(&format!(" project=\"{}\"",
            crate::owl::poll::event_attr_escape(&self.project)));
        if let Some(b) = &self.branch {
            out.push_str(&format!(" branch=\"{}\"",
                crate::owl::poll::event_attr_escape(b)));
        }
        if let Some(s) = &self.head_sha {
            out.push_str(&format!(" head_sha=\"{}\"",
                crate::owl::poll::event_attr_escape(s)));
        }
        if let Some(s) = &self.head_subject {
            out.push_str(&format!(" head_subject=\"{}\"",
                crate::owl::poll::event_attr_escape(s)));
        }
        out
    }

    /// Render as YAML front-matter block (5 key:value lines + fences).
    /// Same omission policy: absent keys are skipped, not empty-string-set.
    pub fn yaml_frontmatter(&self) -> String {
        let mut out = String::from("---\n");
        out.push_str(&format!("machine: {}\n", yaml_escape(&self.machine)));
        out.push_str(&format!("project: {}\n", yaml_escape(&self.project)));
        if let Some(b) = &self.branch {
            out.push_str(&format!("branch: {}\n", yaml_escape(b)));
        }
        if let Some(s) = &self.head_sha {
            out.push_str(&format!("head_sha: {}\n", yaml_escape(s)));
        }
        if let Some(s) = &self.head_subject {
            out.push_str(&format!("head_subject: {}\n", yaml_escape(s)));
        }
        out.push_str("---\n");
        out
    }
}

fn cap_subject_72(raw: &str) -> String {
    // Strip embedded newlines (Pitfall 2): take only first line.
    let line = raw.lines().next().unwrap_or("").trim();
    let chars: Vec<char> = line.chars().collect();
    if chars.len() <= 72 {
        line.to_string()
    } else {
        let head: String = chars.iter().take(72).collect();
        format!("{}…", head)
    }
}

fn yaml_escape(s: &str) -> String {
    // Conservative YAML scalar escape: quote if contains : or # or
    // starts/ends with whitespace. Otherwise emit bare.
    if s.contains(':') || s.contains('#') || s.contains('"')
        || s.starts_with(' ') || s.ends_with(' ') {
        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
    } else {
        s.to_string()
    }
}

fn hostname() -> String {
    #[cfg(windows)]
    let env_var = "COMPUTERNAME";
    #[cfg(unix)]
    let env_var = "HOSTNAME";
    if let Ok(v) = std::env::var(env_var) {
        if !v.is_empty() { return v; }
    }
    // Fallback: shellout to `hostname`.
    let mut cmd = Command::new("hostname");
    crate::common::process::hide_window(&mut cmd);
    if let Ok(out) = cmd.stdout(Stdio::piped()).stderr(Stdio::null()).output() {
        if out.status.success() {
            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !s.is_empty() { return s; }
        }
    }
    "unknown".to_string()
}

fn git_project_basename(cwd: &std::path::Path) -> Option<String> {
    run_git_with_timeout(&["rev-parse", "--show-toplevel"], cwd)
        .and_then(|abs| std::path::Path::new(&abs)
            .file_name()
            .and_then(|s| s.to_str())
            .map(|s| s.to_string()))
}

fn run_git_with_timeout(args: &[&str], cwd: &std::path::Path) -> Option<String> {
    let mut cmd = Command::new("git");
    crate::common::process::hide_window(&mut cmd);
    let mut child = cmd
        .arg("-C").arg(cwd)
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .stdin(Stdio::null())
        .spawn()
        .ok()?;
    let pid = child.id();
    let (tx, rx) = mpsc::channel::<()>();
    let killer = std::thread::spawn(move || {
        if rx.recv_timeout(Duration::from_millis(500)).is_err() {
            crate::common::process::force_kill_process(pid);
        }
    });
    let result = child.wait_with_output();
    let _ = tx.send(());
    let _ = killer.join();
    let out = result.ok()?;
    if !out.status.success() { return None; }
    String::from_utf8(out.stdout).ok().map(|s| s.trim().to_string())
}
```

### Example 2: download_payload extension — emit two structured blocks

```rust
// src/live/context.rs (inside download_payload, after memformat append, BEFORE context.md raw read)
let ctx_path = context_dir().join(format!("{}.md", self_id));
let stored = if ctx_path.exists() {
    parse_yaml_frontmatter(&ctx_path) // returns Option<Stamp>
} else { None };

if let Some(s) = &stored {
    out.push_str(&format!("<psyche-stamp{}/>\n", s.event_attrs_xml_self_closing()));
    has_any = true;
}

let current = crate::common::git::stamp();
let commits_since = stored.as_ref()
    .and_then(|s| s.head_sha.as_ref())
    .and_then(|stored_sha| crate::common::git::commits_since(stored_sha))
    .unwrap_or(0);
let commits_unpulled = crate::common::git::commits_unpulled().unwrap_or(0);

out.push_str(&format!(
    "<current{} commits_since=\"{}\" commits_unpulled=\"{}\"/>\n",
    current.event_attrs(),
    commits_since,
    commits_unpulled,
));
has_any = true;

// Same-project drift directive (D-09)
if let Some(stored) = &stored {
    if stored.project == current.project
        && (stored.branch != current.branch
            || stored.head_sha != current.head_sha
            || stored.machine != current.machine)
        && !is_suppressed(self_id, &current.project)
    {
        out.push_str(SAME_PROJECT_DRIFT_DIRECTIVE);
    }
}
```

### Example 3: AskUserQuestion directive text (D-09, locked verbatim)

```rust
const SAME_PROJECT_DRIFT_DIRECTIVE: &str = r#"
<!-- ATTENTION SELF: this project has advanced since your last commune/signoff.
Compare <psyche-stamp/> against <current/> above.
Fire AskUserQuestion with:
  header: "Project drift"
  question: "This project has advanced since my involvement. Should I catch up?"
  multiSelect: true
  options:
    - "Observe new commits"      # run git log {stored_sha}..HEAD
    - "Skip for now"              # no-op
    - "Don't ask again"           # writes per-(self_id, project) suppression marker
NOTE: "Peek at peer contexts" option is NOT YET AVAILABLE in v1.8 Phase 23 and MUST NOT be offered.
-->
"#;
```

Phase 24/25 unlocks the `Peek at peer contexts` option; planner adds a conditional emit at that time.

### Example 4: YAML front-matter parser (hand-rolled, ~20 lines)

```rust
fn parse_yaml_frontmatter(path: &std::path::Path) -> Option<Stamp> {
    let content = std::fs::read_to_string(path).ok()?;
    if !content.starts_with("---\n") { return None; }
    let body = &content[4..];
    let end = body.find("\n---\n")?;
    let block = &body[..end];

    let mut machine = None;
    let mut project = None;
    let mut branch = None;
    let mut head_sha = None;
    let mut head_subject = None;

    for line in block.lines() {
        let (key, val) = match line.split_once(": ") {
            Some(kv) => kv,
            None => continue,
        };
        let val = unquote_yaml(val);
        match key.trim() {
            "machine" => machine = Some(val),
            "project" => project = Some(val),
            "branch" => branch = Some(val),
            "head_sha" => head_sha = Some(val),
            "head_subject" => head_subject = Some(val),
            _ => {}
        }
    }
    Some(Stamp {
        machine: machine?,
        project: project?,
        branch, head_sha, head_subject,
    })
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Prose form `COMMUNE (ts): body` | EVENT envelope `<EVENT type="commune" timestamp="..." ...>body</EVENT>` | This phase (D-06) | Clean cutover, no legacy fallback. Wrapper-side parsers must already accept it (verified: `compose_commune_payload` for file_drop already lives at `src/live/wrapper/mod.rs:470`) |
| Bare 3 EVENT-attrs on signoff/echo (`from`, `timestamp`, `note`) | 8 attrs (existing 3 + 5 stamp attrs) | This phase (D-05) | Wrapper case-insensitive predicate `is_init_signoff_envelope` unaffected (substring match on lowered type=) |
| Plain markdown context save | Markdown + YAML front-matter at file head | This phase (D-07) | Backward compat: pre-Phase-23 files have no front-matter → `<psyche-stamp/>` omitted, `<current/>` still emitted |
| psyche-download = memformat + context.md raw + pending sections | + 2 structured blocks (`<psyche-stamp/>`, `<current/>`) + drift directive | This phase (D-08/D-09/D-10) | Additive; existing consumers (SessionStart hook, CLI `psyche-download`) keep working — new blocks are inert XML unless explicitly read |

**Deprecated/outdated:**
- The `COMMUNE (ts): body` prose form in `src/live/commune.rs::run` lines 27-28 and `commune_result` lines 86-87.
- Tests that lock the prose form (search needed in Step 5).

## Assumptions Log

> Claims tagged `[ASSUMED]` in this research.

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `$HOSTNAME` may be unset on non-bash Linux logins | Hostname strategy | Low — fallback to `hostname` shellout covers it; if shellout fails, `"unknown"` is emitted (still valid per D-01) |
| A2 | `force_kill_process` on Windows succeeds against any child we just spawned | Pattern 3 | Low — `OpenProcess(PROCESS_TERMINATE)` on a same-user child is well-documented to succeed; tests on Windows CI will catch any drift |
| A3 | 72-char cap in D-04 means char count, not byte count | Pitfall 3 | Medium — if user intent was bytes, the fix is `.bytes().take(72)` with extra care for UTF-8 boundary. Surface in Open Question 3 |
| A4 | `compose_commune_payload` (wrapper-side, Phase 30) is the right additional 4th call site | Architecture Map | Low — verified by reading src/live/wrapper/mod.rs:470 and the dispatch at line 1196 |
| A5 | Echo-commune stamp falls back to psyche_dir basename when Self project lookup fails | Pitfall 5 | High — D-14 explicitly states this fallback; the question is whether v1.8 ships with the fallback as the steady state (Phase 24.1 fixes it) or whether Phase 23 extends perch info.json with a `project` field now. Recommendation: defer to Phase 24.1; Phase 23 stamps psyche_dir basename in this corner case. **Planner must lock this** |
| A6 | Suppression marker lives at `$SPT_HOME/suppressions/{self_id}__{project}.marker` | Pitfall 6 | Low — Phase 24 owns migration; Phase 23 plants the marker, Phase 24 moves it |
| A7 | Pre-Phase-23 context files (no front-matter) gracefully degrade to "no `<psyche-stamp/>`, only `<current/>`" | Runtime State Inventory | Low — verified by reading `parse_yaml_frontmatter` semantics; returns `None` cleanly |
| A8 | Adding 5 new attrs to existing EVENT envelopes does NOT break `is_init_signoff_envelope` predicate or test fixtures | Pitfall 8 | Verified — predicate is substring `<event type="init_signoff"`; fixture asserts `starts_with("<EVENT type=\"init_signoff\" timestamp=\"")` |
| A9 | Receiving wrappers running v1.7.1 (mid-handoff) tolerate unknown new attrs | code_context "Integration Points" in CONTEXT | Low — attrs are additive; existing parsers extract specific named attrs (`from`, `timestamp`, `note`) by name, ignoring others |
| A10 | The `<EVENT type="commune">` envelope (D-06) is NEW from Self's CLI; Phase 30 already had it on wrapper file_drop path | Pitfall (none — confirmation) | Low — verified `src/live/wrapper/mod.rs:470` |

## Open Questions (RESOLVED)

All six questions resolved during planning; resolutions implemented across plans 23-01..23-06. Listed here for traceability.

1. **Where does the suppression marker live, and what writes it?** — **RESOLVED:** New `$LIVE suppress-drift` subcommand (Plan 23-05). Marker path: `$SPT_HOME/suppressions/{self_id}__{project}.marker`. One owl.exe surface to test; cleaner than teaching Claude a path literal.

2. **Phase 23 extend perch info.json with `project` field, or accept psyche_dir basename fallback for v1.8?** — **RESOLVED:** Accept fallback per D-14 (Plan 23-02, `resolve_self_project_stamp` helper with `TODO(phase-24.1)` marker). Echo-commune stamps are forensic, not user-visible. Phase 24.1 ships canonical `last_project_name`.

3. **72-char cap (D-04) — chars or bytes?** — **RESOLVED:** chars. Pitfall 3 mitigated. Plan 23-01 `cap_subject_72_emoji_safe` test asserts `chars().count() <= 73`.

4. **`<current/>` block ALWAYS emit, or only with a `<psyche-stamp/>` to diff against?** — **RESOLVED:** Always emit (unconditionally), but does NOT set has_any — preserving the existing Some/None NO-CONTEXT contract. Plan 23-04 Task 2.

5. **`git rev-list --count HEAD..@{upstream}` safe under 500ms timeout with no network?** — **RESOLVED:** Yes — local-ref-only operation, < 50ms typical regardless of network. On nonzero exit (no upstream), helper returns `None`; caller `.unwrap_or(0)` emits `commits_unpulled="0"`. Plan 23-01 + 23-04.

6. **Golden fixtures need refresh for the 4 affected EVENT envelopes?** — **RESOLVED:** No. Existing goldens cover CLI exit codes + status messages only, not payload shapes. Defensive `cargo test --test golden_live` run in phase verification.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `git` CLI | Stamp helper (D-12) | ✓ (assumed — codebase already shells out at 14+ call sites in `src/live/context.rs`) | any | Per D-11/D-13: emit machine + project only, omit branch/head_sha/head_subject |
| `hostname` CLI | Hostname fallback path | ✓ on Unix (POSIX-mandated); ✓ on Windows 10+ | any | env-var path covers most cases; `"unknown"` literal if both fail |
| `$COMPUTERNAME` env | Hostname primary path on Windows | ✓ (always set on Windows) | — | Shellout to `hostname` |
| `$HOSTNAME` env | Hostname primary path on Unix | Sometimes (set by bash/zsh, not by sh-only logins) | — | Shellout to `hostname` |
| Rust std `std::sync::mpsc` | Timeout monitor thread | ✓ — std | std | None needed |
| Rust std `std::process::Command` | All subprocess invocation | ✓ — std | std | None needed |

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

**Missing dependencies with fallback:** All git failure modes have a graceful degrade — Stamp returns with three Optionals = `None`, attrs omitted, downstream consumers treat absence as "not in repo".

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | `cargo test` — built-in Rust test runner, no external framework |
| Config file | `Cargo.toml` (dev-dependencies block) — no test-specific config |
| Quick run command | `cargo test --lib common::git` (Phase 23 unit suite) |
| Full suite command | `cargo test --workspace` (everything; goldens included on Linux) |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PROJ-META-01 | Stamp 5 fields populated from cwd repo | unit | `cargo test --lib common::git::stamp_produces_five_fields_in_repo` | ❌ Wave 0 |
| PROJ-META-02 | Cwd outside repo → only machine+project | unit | `cargo test --lib common::git::stamp_omits_optional_outside_repo` | ❌ Wave 0 |
| PROJ-META-03 | 500ms timeout treats git-hang as not-a-repo | unit (mocked or skipped on CI; use `Command::new("sleep")` adapter) | `cargo test --lib common::git::stamp_timeout_degrades` | ❌ Wave 0 |
| COMMIT-META-01 | head_subject capped at 72 chars + ellipsis | unit | `cargo test --lib common::git::cap_subject_72_emoji_safe` | ❌ Wave 0 |
| COMMIT-META-02 | Each composer emits 5 attrs in correct order | unit | `cargo test --lib live::commune::compose_with_stamp` etc. | ❌ Wave 0 (per composer) |
| COMMIT-META-03 | EVENT envelope `type="commune"` shape (D-06) | unit | `cargo test --lib live::commune::compose_event_envelope_replaces_prose` | ❌ Wave 0 |
| YAML-META-01 | YAML front-matter prepended on context-save | unit | `cargo test --lib live::context::context_save_writes_frontmatter` | ❌ Wave 0 |
| YAML-META-02 | YAML front-matter prepended inside amend-signoff section | unit | `cargo test --lib live::context::amend_signoff_writes_section_frontmatter` | ❌ Wave 0 |
| RESUME-DELTA-01 | `<psyche-stamp/>` block emitted from stored front-matter | unit | `cargo test --lib live::context::download_emits_psyche_stamp_block` | ❌ Wave 0 |
| RESUME-DELTA-02 | `<current/>` block emitted with live values | unit | `cargo test --lib live::context::download_emits_current_block` | ❌ Wave 0 |
| RESUME-DELTA-03 | Same-project drift triggers AskUserQuestion directive | unit | `cargo test --lib live::context::download_emits_drift_directive_on_same_project` | ❌ Wave 0 |
| RESUME-DELTA-04 | Cross-project resume stays silent (D-10) | unit | `cargo test --lib live::context::download_silent_on_cross_project` | ❌ Wave 0 |
| RESUME-DELTA-05 | "Don't ask again" suppression honored | unit | `cargo test --lib live::context::download_honors_suppression` | ❌ Wave 0 |
| RESUME-DELTA-06 | `commits_since` count via `git rev-list --count` | unit | `cargo test --lib common::git::commits_since` | ❌ Wave 0 |
| RESUME-DELTA-07 | `commits_unpulled` returns 0 when no upstream (D-08) | unit | `cargo test --lib common::git::commits_unpulled_no_upstream` | ❌ Wave 0 |
| ECHO-DELTA-01 | echo_commune stamp uses Self project (D-14 fallback) | unit (Pitfall 5 corner) | `cargo test --lib owl::echo_commune::compose_uses_self_project` | ❌ Wave 0 |
| BACK-COMPAT-01 | Pre-Phase-23 context files (no front-matter) degrade cleanly | unit | `cargo test --lib live::context::download_no_frontmatter_emits_only_current` | ❌ Wave 0 |
| BACK-COMPAT-02 | Wrapper case-insensitive predicate still matches new envelope | unit | (existing test in signoff.rs:152 — extend to assert new attrs present) | ⚠️ extend existing |

### Sampling Rate
- **Per task commit:** `cargo test --lib common::git` (≈ 8 new unit tests, sub-second)
- **Per wave merge:** `cargo test --lib` (excludes goldens — Windows-friendly)
- **Phase gate:** `cargo test` (full suite, Linux for goldens) — pre-merge

### Wave 0 Gaps
- [ ] `src/common/git.rs` — entire new module + tests (this file does not exist yet)
- [ ] Test module inside `src/common/git.rs` with `#[cfg(test)]` block (Rust convention — same-file tests)
- [ ] Test extensions inside `src/live/commune.rs`, `src/live/signoff.rs`, `src/owl/echo_commune.rs`, `src/live/context.rs` for `&Stamp`-parameterized composer assertions
- [ ] Integration test: `tests/phase23_drift_detection.rs` — subprocess-driven, covers RESUME-DELTA-03 + RESUME-DELTA-04 end-to-end against a tempdir SPT_HOME
- [ ] Framework install: **none — `cargo test` is built-in**

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | — |
| V3 Session Management | no | — |
| V4 Access Control | no | — |
| V5 Input Validation | yes | `event_attr_escape` for all attr values; YAML parser rejects malformed front-matter on first violation (Pitfall 4) |
| V6 Cryptography | no | — |
| V7 Error Handling | yes | All git subprocess failures degrade silently per D-11/D-13; rate-limited stderr warning per Pattern 4 |
| V12 File and Resource | yes | YAML parser must NOT follow symlinks out of `psyche_dir`; suppression marker write must NOT traverse `..` |

### Known Threat Patterns for {Rust subprocess + EVENT envelope schema}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Hostile commit message in `head_subject` containing `"` or `</EVENT>` | Tampering | Cap at 72 chars (D-04) + `event_attr_escape` — proven amp-first ordering |
| Hostile branch name (`refs/heads/<script>...`) | Tampering | Branch name comes from `git rev-parse --abbrev-ref HEAD` which only emits valid ref chars; still apply `event_attr_escape` belt-and-braces |
| Symlinked context file in `psyche_dir` aimed at `/etc/passwd` | Tampering (read) | YAML parser uses `std::fs::read_to_string(path)` which follows symlinks — Phase 23 inherits whatever guarantees `psyche_dir` already has. Verified: `psyche_dir` is under `$SPT_HOME` which is owner-controlled |
| Suppression marker path traversal (`{self_id}=../../etc/foo`) | Tampering (write) | `$LIVE suppress-drift` subcommand MUST validate self_id and project via existing `id_validate` patterns + reject `..` segments |
| git subprocess hung on a malformed repo (e.g., `.git/HEAD` containing 100MB of binary) | DoS | 500ms timeout (D-13) bounds worst-case wait |
| Hostile YAML key collision (`machine: foo\nmachine: bar`) | Tampering | Parser uses last-wins (`Some(val)` overwrites); document this. Lower risk because the front-matter is written by Phase 23's OWN composer — only an attacker with write access to psyche_dir can plant a hostile file, and that grants them everything anyway |

## Sources

### Primary (HIGH confidence — read in full this session)
- `src/live/commune.rs` (203 lines) — current prose form + structured result
- `src/live/signoff.rs` (236 lines) — compose_init_signoff_payload + tests
- `src/owl/echo_commune.rs` (1398 lines) — compose_echo_commune_payload + dispatch_commune_markers + full test suite
- `src/live/context.rs` (1275 lines) — run_save / run_amend_signoff / download_payload / append_pending_sections + tests
- `src/owl/poll.rs` (lines 648-668) — event_attr_escape / event_body_escape definitions
- `src/common/owlery.rs` (lines 100-478) — psyche_dir / last_commune_epoch_path / Phase 32 derive_current_repo_names + git remote shellout pattern
- `src/common/process.rs` (252 lines) — hide_window / force_kill_process cross-platform pattern
- `src/common/time.rs` (103 lines) — format_timestamp
- `src/live/wrapper/mod.rs` (lines 148-156, 264-472, 1100-1245, 2380-2440) — is_init_signoff_envelope, compose_commune_payload (Phase 30), process_file_drop, tests
- `src/live/wrapper/claude.rs` (lines 420-465) — parse_markers
- `.planning/phases/23-commune-signoff-project-root-head-sha-stamping/23-CONTEXT.md` — all 14 decisions
- `.planning/phases/23-commune-signoff-project-root-head-sha-stamping/23-DISCUSSION-LOG.md` — alternatives considered (4 areas)
- `.planning/ROADMAP.md` (lines 389-475) — Phase 23 + Phase 24 + Phase 24.1 forward-compat
- `plugin/spt/skills/live/SKILL.md` — AskUserQuestion patterns, FRESH-02 NO-CONTEXT predicate, EVENT envelope reference
- `plugin/spt/skills/commune/SKILL.md` — commune flow
- `plugin/spt/skills/signoff/SKILL.md` — signoff flow
- `Cargo.toml` — dep set verified zero-additions
- `tests/golden_live.rs` (full file) + `tests/golden/live/*` listing — verified no commune/signoff payload shape fixtures
- `tests/golden/live/context_save.stdout` + `psyche_download_exists.stdout` — confirmed text-content-only

### Secondary (MEDIUM confidence — pattern inferred from existing usage)
- 500ms soft-timeout for `git` via spawn + monitor thread — no existing example in repo; pattern designed to use only std + existing `force_kill_process` helper. Validated by reading `force_kill_process` cross-platform implementation.

### Tertiary (LOW confidence — flagged for validation)
- A1 (`$HOSTNAME` set state across Linux shells) — assumption from general POSIX knowledge, not verified per-distro
- A3 (72-char cap interpretation) — D-04 wording is ambiguous; planner should confirm with user

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every reused crate already pinned + already exercised in commune/signoff/echo paths
- Architecture: HIGH — touchpoints read in full; every D-01..D-14 mapped to a concrete file/function
- Pitfalls: HIGH — eight pitfalls catalogued, each tied to a verifiable invariant in existing code
- Timeout pattern: MEDIUM — no prior art in repo; pattern uses only std primitives and existing cross-platform helpers; will be the first timeout pattern in the codebase

**Research date:** 2026-05-19
**Valid until:** 2026-06-18 (30 days — Rust std + git CLI surfaces are stable; Phase 24/25 may force a re-read of the forward-compat sections)
