---
phase: quick-260418-nt3
plan: 01
subsystem: live/wrapper
tags: [echo-commune, gate, sentinel, amendment, phase-18.3]
dependency_graph:
  requires:
    - src/owl/hook_idle.rs (.more-done sentinel writer — unchanged)
    - src/live/wrapper/mod.rs (WrapperState — unchanged)
  provides:
    - src/live/wrapper/echo_fire.rs (sentinel-age gate)
  affects:
    - Psyche echo-commune cadence (no longer suppressed by trivial ctx writes)
tech_stack:
  added: []
  patterns:
    - "std::fs::metadata().created() with modified() fallback for portable birthtime"
    - "Duration::ZERO window portability trick (avoids filetime dep)"
key_files:
  created: []
  modified:
    - src/live/wrapper/echo_fire.rs
decisions:
  - "Gate now measures sentinel age (creation time, mtime fallback) instead of Self ctx mtime"
  - "hook_idle.rs deliberately NOT edited — sentinel lifecycle already correct per lifecycle audit"
  - "mtime fallback for legacy ext4/NFS accepted as minor degradation (staleness clock resets per Stop instead of per fire); 15-min window dwarfs typical inter-Stop intervals"
  - "SkipCtxFresh variant renamed to SkipSentinelFresh; log messages updated ctx→sentinel for grep clarity"
requirements:
  - AMEND-18.3-01
metrics:
  tasks: 2
  files_changed: 1
  duration_minutes: ~5
  completed: "2026-04-18"
---

# Quick Task 260418-nt3: Phase 18.3 Amendment — Echo Commune Gate Summary

Single-file amendment to `src/live/wrapper/echo_fire.rs` swapping the gate's
"staleness" signal from the Self context file's mtime to the `.more-done`
sentinel's own age. Psyche's own trivial per-PULSE context writes were
masking the gate — the sentinel is written ONLY by the Stop hook, so its
age is the accurate "time since last un-summarized Self turn" signal.

## Before / After: `should_fire_decision` semantics

### Before

```rust
pub(crate) fn should_fire_decision(
    sentinel: &Path,
    ctx_file: &Path,
    window: Duration,
) -> FireDecision {
    if !sentinel.exists() { return FireDecision::SkipNoSentinel; }
    match std::fs::metadata(ctx_file).and_then(|m| m.modified()) {
        Ok(mtime) => match mtime.elapsed() {
            Err(_) => FireDecision::Fire,
            Ok(age) if age >= window => FireDecision::Fire,
            Ok(age) => FireDecision::SkipCtxFresh {
                remaining: window.saturating_sub(age),
            },
        },
        Err(_) => FireDecision::Fire, // missing ctx = infinitely old
    }
}
```

Signal: *"Self context has not been touched in N seconds."*
Problem: Psyche writes pulse-counter updates to Self ctx on almost every
PULSE. The gate saw "fresh ctx" every ~45-60s and suppressed echo-commune
indefinitely.

### After

```rust
pub(crate) fn should_fire_decision(sentinel: &Path, window: Duration) -> FireDecision {
    if !sentinel.exists() { return FireDecision::SkipNoSentinel; }
    match sentinel_age(sentinel) {
        None => FireDecision::Fire,
        Some(age) if age >= window => FireDecision::Fire,
        Some(age) => FireDecision::SkipSentinelFresh {
            remaining: window.saturating_sub(age),
        },
    }
}

fn sentinel_age(path: &Path) -> Option<Duration> {
    let md = std::fs::metadata(path).ok()?;
    let t = md.created().or_else(|_| md.modified()).ok()?;
    Some(t.elapsed().unwrap_or(Duration::from_secs(u64::MAX)))
}
```

Signal: *"The .more-done sentinel is more than N seconds old."*
Because the Stop hook uses `fs::write` (create-if-absent, truncate-if-
present) and BOTH Windows NTFS (file tunneling) and Unix (POSIX inode
birthtime immutability) preserve creation time across truncation, the
sentinel's birthtime = timestamp of the OLDEST un-fired Stop since the
last echo-commune fire.

## hook_idle.rs: NOT edited

The `<lifecycle_audit>` in the plan confirmed the Stop-hook side was
already correct:

1. Stop hook writes sentinel via `fs::write` → birthtime preserved on
   truncate.
2. Wrapper deletes sentinel before spawn (D-09) → next Stop starts a
   fresh inode with a fresh birthtime.

The new `lifecycle_delete_then_recreate_resets_age` test pins this
contract at the `echo_fire.rs` layer (without a cross-module harness —
zero-dep constraint).

## Platform note: `created()` vs `modified()` fallback

| Filesystem | `md.created()` | Semantics delivered |
|------------|---------------|---------------------|
| Windows NTFS | Always supported | Birthtime = oldest un-fired Stop (intended) |
| Linux ext4 (modern, >= 4.11 + statx) | Supported | Same as NTFS (intended) |
| Linux ext4 (legacy) | `io::ErrorKind::Unsupported` → mtime fallback | Mtime = MOST-RECENT Stop (minor degradation: clock resets every Stop instead of per-fire-delete-recreate) |
| NFS (some mounts) | Unsupported → mtime fallback | Same as legacy ext4 |
| macOS APFS / HFS+ | Supported | Intended |

The mtime-fallback degradation is acceptable: the 15-minute window
(ECHO_COMMUNE_WINDOW) dwarfs typical inter-Stop intervals, so "fires when
mtime hits 15 min old" and "fires when birthtime hits 15 min old" both
converge on the same user-observable cadence — echo-commune fires once
the agent has been quiet for 15 minutes.

## Test suite

`cargo test --lib wrapper::echo_fire` → **12 passed** (was 9 pre-amendment)

| Test | Status |
|------|--------|
| `echo_commune_window_constant_is_15_min` | unchanged |
| `no_fire_when_sentinel_absent` | adapted (signature) |
| `fire_when_sentinel_stale_via_zero_window` | renamed from `fire_when_sentinel_present_and_ctx_missing` |
| `no_fire_when_sentinel_fresh` | renamed from `no_fire_when_ctx_fresh` |
| `decision_skip_no_sentinel_when_absent` | adapted |
| `decision_fire_on_zero_window` | renamed from `decision_fire_when_sentinel_present_and_ctx_missing` |
| `decision_skip_sentinel_fresh_when_sentinel_just_written` | renamed |
| `decision_fire_when_sentinel_stale_via_zero_window` | renamed |
| `clamp_short_pulse_floor_is_60` | unchanged |
| `sentinel_age_uses_creation_time_on_supported_fs` | **new** |
| `missing_sentinel_returns_skip_no_sentinel` | **new** |
| `lifecycle_delete_then_recreate_resets_age` | **new** (Task 2) |

Full `cargo test --lib` = 53 passed, 0 failed (no regressions).

## Verification checklist

- [x] `should_fire_decision` and `should_fire` take only `(&Path, Duration)`
- [x] `FireDecision::SkipSentinelFresh` exists; `SkipCtxFresh` removed
- [x] `sentinel_age` helper exists, uses `created()` with `modified()` fallback
- [x] `fire_echo_commune_if_due` no longer references `context::context_dir()`
- [x] `use crate::live::context;` import removed
- [x] Module doc comment updated (sentinel age vs ctx mtime)
- [x] Log messages updated: "sentinel fresh" / "sentinel stale"
- [x] `cargo check` clean (no new warnings; pre-existing `should_fire` unused-fn warning remains because it's a test-only adapter)
- [x] `grep SkipCtxFresh src/` — no matches
- [x] `grep "context::context_dir" src/live/wrapper/echo_fire.rs` — no matches
- [x] All 53 lib tests pass

## Commits

| Task | Hash | Message |
|------|------|---------|
| 1 | `0794a6d` | feat(quick-260418-nt3): gate echo commune on sentinel age, not ctx mtime |
| 2 | `801a8e8` | test(quick-260418-nt3): pin sentinel lifecycle — fire-delete-recreate resets clock |

## Deviations from Plan

None — plan executed exactly as written.

Note on process: Task 1 (semantic change + existing-test adapt + 2 new tests)
and Task 2 (add 1 new lifecycle test) both edit the same file. I wrote both
in one pass, then split the commits by temporarily removing the lifecycle
test, committing Task 1, re-adding the lifecycle test, committing Task 2.
This preserves the atomic-per-task commit protocol without duplicating work.

## Deployment

This is a code-only amendment. Deployment (rebuild release, copy
`target/release/owl.exe` to `~/.claude/skills/owl/owl.exe`) is user
responsibility per the quick-task constraints.

## Self-Check: PASSED

- FOUND: src/live/wrapper/echo_fire.rs (modified)
- FOUND commit: 0794a6d (Task 1)
- FOUND commit: 801a8e8 (Task 2)
- All 12 echo_fire tests pass; all 53 lib tests pass; no new warnings
