# Next milestone — picker/presence truth + project-history UX (operator-surfaced 2026-07-02)

Eight operator items diagnosed by doyle (live evidence on HFENDULEAM + ENLYZEAM +
hfenduleam's SPT_DEV registry snapshot + code walk @04a3622; operator feedback folded
2026-07-03). Four bugs (+one store RCA), two features, two UX/surface changes. Evidence
artifacts quoted inline; every fix names its red-first seam.

Working name: **BUILD-F026-PICKERTRUTH**. Base: main @e96f7d0 (v0.21.0).

---

## #1 — Details panel shows ONE wrong project ("f015b-probe-psyche") for `hall-a`

**Answer to the operator's purge question first: hall-a was NOT purged.** Its
`sessions.log` is continuous since creation (2026-07-02 08:12Z) and holds the full story:

```
ordinal 0  boot   cwd=C:\Users\decid\Documents\projects            <- the ORIGINAL "projects"
ordinal 1-3       cwd=...\Documents\projects\spt-core
ordinal 4  boot   cwd=...\spt-core\owlery\hall-a\nested\hall-a-psyche
ordinal 5  boot   cwd=...\owlery\f015b-probe\nested\f015b-probe-psyche   <- current info.json cwd
```

**Root cause — three stacked defects** (`crates/spt/src/picker/data.rs`):

- **1a.** `project_history_for()` (data.rs:372) reads ONLY context-store `p-*` branches.
  This box's store (`tracked/.seed.git`) has **zero branches** (`git for-each-ref` empty)
  → history is `[]` for every endpoint. (Why the store is empty is its own
  sub-investigation, **SI-1** below — the picker must degrade better regardless.)
- **1b.** The fallback "origin project" (data.rs:207) is derived from `info.json.cwd`,
  which is **rewritten on every rebind** — it is "latest boot cwd", not origin. hall-a's
  latest rebind was into the f015b-probe **psyche** dir, so that's what displays.
- **1c.** Psyche-host sessions bind into the agent's perch with owlery-internal cwds
  (`owlery\*\nested\*-psyche`) — internal dirs pollute the cwd history and get presented
  as "projects".

**Fix shape:** derive project history = `sessions.log` cwds (newest→oldest, deduped by
`project_id_for_dir`) UNION context-store branches; **exclude owlery-internal paths**
(any cwd under `spt_home()/owlery`) from project derivation everywhere a "project" is
displayed or inferred. Keep full dirs available (feature #5 needs them).
**Red-first seam:** unit over `project_history_for`-successor fed a fixture sessions.log
(projects → spt-core → psyche-dir) + empty store → expect `[spt-core, projects]`, no
psyche entry. Confidence HIGH / effort M.

**SI-1 (PROMOTED to full milestone item — operator ruling 2026-07-03):** RCA why
`tracked/.seed.git` holds no `p-*` branches on a box with months of live-agent use —
context commits never landing is either a commune-ingest/context-commit regression or a
store re-init; check kitsubito's + enlyzeam's stores for contrast, walk the commit path
(contextstore write → branch commit) with evidence, and land whatever fix the RCA names.
The picker fallback in 1a ships regardless, but the store must ALSO be made to fill.

**SI-1 RCA VERDICT + FIX (todlando 2026-07-03, doyle RCA-gate).** ROOT: the daemon
used the adapter's RELATIVE `commune_dir`/`signoff_dir` (`.claude`) VERBATIM
(`BrainLifecycle::with_config_in` `PathBuf::from`) — never resolved against the
endpoint's cwd as the manifest documents — so `ingest_drops` scanned `.claude` relative
to the DAEMON's process cwd (`exists()==false` every pulse → zero ingest → the two-tier
BranchStore stayed pristine box-wide; the frozen `project_id=project_id_for_dir("")` was
garbage; abs-only test fixtures masked it). FIX: resolve drop dirs + derive `project_id`
AT INGEST TIME against `info.cwd` (`pulse_tick`) — absolute as-is, relative+cwd joined,
relative+no-cwd skipped loud-once; owlery-internal anchor routes no `p-<project>` (live
slice still commits); pure back-compat (no adapter republish). See KNOWN-HAZARDS §7.28
(class rule) + `REQ-STORE-CONTEXT-BRANCH-FILL`. **SECONDARY → perri consumable (release
ping):** `p-*` fills only if the commune body carries a `<project-context>` slice —
claude-spt's commune authoring must emit one, else only `a-<id>` (live/agent) fills.

---

## #2 — "ONLINE + CONTROLLED" survives `/exit` (~1 min, local tab)

**The flag never clears at all — the ~1 min is liveness decay masking it.** Live
evidence: hours after the operator's RC `/exit`, hall-a's `info.json` still reads
`"controlled":true` (status offline, dormant), and hfenduleam still **gossips**
`controller_node=<self>` for hall-a (SPT_DEV.json snapshot) — the stale latch propagates
cross-node.

Operator mechanism note (2026-07-03): `/exit` DOES end the RC session too — indirectly,
via the endpoint suspending when the child binary terminates. Consistent with the RCA:
the RC conn dies as a CONSEQUENCE of child exit, so the teardown runs the reap path (no
stamp clear), never the controller-detach path (which clears).

**Root cause** (`crates/spt-daemon/src/broker.rs`): the exit-waiter thread (~:1844)
sends the exit frame and `sessions.remove(&id)` — **no stamp clear on the reap path**.
`clear_controller()` → `stamp_driven_by()` (clears `driven_by` + `controlled`) runs only
on controller-detach/evict/displace. `/exit` kills the CHILD, not the controller conn,
so the OutputLog drops with the controller slot occupied and `controlled:true`,
`viewer_count`, (and `driven_by` for a remote controller) latch in info.json forever.

**Fix shape:** on session reap, clear the perch's controller/viewer stamps
(`set_driven_by(None)` + `set_controlled(false)` + `set_viewer_count(0)` via the known
endpoint id) — broker stays the single writer. Mint the hazard:
**REQ-HAZARD-CONTROL-STAMP-LIFETIME** — "a control/viewer stamp never outlives its
session; every teardown path clears what attach stamped."
**Red-first seam:** int (real broker, applyhost-test style): spawn hosted session →
attach controller → kill child → wait reap → assert info.json `controlled==false`,
`driven_by==None`, `viewer_count==0`. Confidence HIGH / effort S-M.

The residual ~1 min ONLINE tail after child death is the roster liveness decay window —
separate, lower-priority; re-evaluate after the stamp fix lands (it may read fine once
CONTROLLED stops lying).

---

## #3 — ball-b CONTROLLED on enlyzeam shows plain ONLINE (both pickers)

Two independent halves.

**Local half (code bug, both machines):** `display_status()`
(`crates/spt/src/picker/model.rs:415`) derives Controlled **only** from
`driven_by.is_some()` — and the legacy local attach path left `driven_by` None.
(**MODEL CORRECTION**, doyle ruling v3 2026-07-19, applied here 2026-08-19: this
line read "`driven_by` is REMOTE-only by design (KH 7.15)". Falsified —
`driven_by` names the controlling node, own node included, and the own-hex latch
is truthful; KH 7.15 is about clearing the stamp once the session is gone. The
diagnosis and fix below stand unchanged.) A locally-controlled endpoint of that
era has `driven_by=None` + `controlled=true`, and `local_rows`
(data.rs:220) never threads `controlled` into `EndpointRow` — so a locally-RC'd endpoint
renders plain ONLINE in its own node's picker. Remote rows DON'T have this hole (gossip
stamps `controller_node=self` for a local controller — REQ-GOSSIP-CONTROLLED-ANY) —
that asymmetry is the bug.
**Fix shape:** `EndpointRow` gains `controlled: bool` (local: `rec.controlled`; remote:
`controller_node.is_some()`); `display_status` → Controlled when
`driven_by.is_some() || controlled`; desc pane says "controlled locally" when the driver
is unnamed. **Red-first seam:** unit — locally-controlled row (driven_by None,
controlled true) must render `EpDisplay::Controlled`. Confidence HIGH / effort S.

**Remote half (ops, enlyzeam) — RESOLVED 2026-07-03:** hfenduleam's snapshot row for
ball-b carried **no `controller_node`, no `resources`, status=Suspended, bound=false**
while the endpoint was actually online+controlled — ENLYZEAM was gossiping with stale
legs (old running image; F-025's exact dormancy class). Operator updated + bounced the
daemon; doyle re-verified on-node: `spt 0.21.0`, **`broker image: 0.21.0 (matches
installed)`** (first field use of the F-025 surface), ball-b info.json clean
(`controlled:false`). Gossip refreshes on pump cadence. NOT a code defect — closed.

**Diagnostic gotcha (recorded):** ENLYZEAM is a **Windows** box; `ssh decid@enlyzeam`
lands in PowerShell. Piping `bash -lc` there drops into a WSL distro as user `decode`
with a separate filesystem — which is why the first probe found "no spt anywhere". Probe
enlyzeam with PowerShell syntax over ssh.

---

## #4 — ball-b details show no "harness" / "project history" on hfenduleam

**Root cause:** those datums are **not gossiped**, and the remote-row builder fakes them
(`crates/spt/src/picker/model.rs:340` `from_resource_row`):

- `project_history: Vec::new()` — hardcoded empty for every remote row.
- `adapter_profile: row.resources` — the **blurb** masquerading as the harness/adapter
  (so "harness" shows blurb-or-empty, never the adapter).
- `Instance`/`ResourceRow` (`crates/spt-net/src/net/registry.rs:457`) carry no adapter
  field and no project list.

Compounded by #3's enlyzeam staleness (its rows lack even `resources`).

**Fix shape:** additive gossip fields, N-1-safe exactly like `endpoint_type`:
`Instance.adapter` (composite `<adapter>[:profile]`) + `Instance.recent_projects`
(bounded, newest-first) → thread to `ResourceRow` → `from_resource_row` stops faking.

**Project representation ruling (operator 2026-07-03): project IDs only, EVERYWHERE —
including local display.** Full paths are clutter locally and leak usernames/dir
structure cross-node. When two entries' IDs collide, disambiguate minimally: append the
one-level-up parent folder and/or the root drive letter (e.g. `spt-core (projects)` vs
`spt-core (D:)`) — a pure `disambiguate_project_ids(entries) -> display names` fn,
unit-tested on collision/no-collision/drive-only cases. (#5's Choose-project panel still
needs the full DIR per entry internally to launch into — dirs stay in the model, IDs in
the display.)

**Red-first seam:** unit — from_resource_row maps adapter/projects verbatim + pre-field
row renders "-"; unit — disambiguation cases; int — gossip round-trip carries them.
Confidence HIGH / effort M.

---

## #5 — FEATURE: "Choose project" follow-up step after "Start now"

When "Start now" is chosen in the endpoint picker, swap the bottom "Options" panel to a
**"Choose project"** list:

1. the endpoint's most recent project dir,
2. `Here: <dir>` — the `spt endpoint run` cwd (only if different),
3. all other project-history dirs, newest→oldest.

Fire the step **only** when (A) the run cwd mismatches a singular history entry, or
(B) history has >1 entry. Otherwise start immediately (today's behavior).

Depends on #1's history rework (needs full **dirs** from sessions.log, not just project
ids; owlery-internal exclusion applies). Start-now is local-only, so no gossip needed.
REQ stub: **REQ-PICKER-START-PROJECT-CHOICE** (pure decision fn
`should_offer_project_choice(run_cwd, history) -> bool` + panel model; unit both
conditions + the skip case). Effort M.

---

## #6 — CHANGE: "Resume from history" keeps the Confirm top panel

Resume view currently replaces the whole screen; it should keep the endpoint's
"Confirm selection" top panel and swap only the bottom panel to "Resume from a prior
session" — user stays contextually informed about what they're picking.
`crates/spt/src/picker/view.rs` (resume screen) + model screen state. REQ stub:
**REQ-PICKER-RESUME-CONTEXT-PANEL** (view unit: resume screen renders confirm-panel
header + resume rows). Effort S.

---

## #7 — FEATURE: endpoint can learn its ATTACHED node (operator add 2026-07-03)

**Use case:** `claude-spt` surfaces the local node's and the attached (controlling)
node's names on every UserPromptSubmit — the agent knows whether getting a file to the
user needs extra steps (user is RC'd in from another machine).

**Recommended shape: `spt api endpoint-info [<id>]`** (JSON). Rationale: `spt api *` is
the harness-contract, agent-facing surface (poll/state/boundary live there; JSON-first;
rides perch identity/auth so the bare no-`<id>` form self-resolves like `whoami`).
`spt endpoint *` is the operator-facing human surface — a human wanting this reads the
picker/details. Alternative `spt endpoint get-info` noted; ruling at REQ-mint time.

Payload (committed DTO, additive-forever): `{ id, endpoint_type, adapter,
local_node: {label, key}, attached_node: {label, key} | null, controlled: bool,
project: <current project id>, cwd, subnets: [...] }` — `attached_node` from the
controller stamps (`driven_by` remote / self-node when `controlled` with no remote
driver), null when uncontrolled.

**Hard dependency: #2 + #3.** The stamps this reads must be honest first — a latched
`controlled:true` (bug #2) would tell every agent it's being driven forever. Sequence
after W1. Adapter-side consumable → perri release-ping on publish (claude-spt
UserPromptSubmit integration). REQ stub: **REQ-API-ENDPOINT-INFO**. Effort M. xtask
docs regen (new CLI surface).

---

## #8 — CHANGE: `spt endpoint list` gains a project column (operator add 2026-07-03)

Second column `<project name>/` — the endpoint's LATEST project — making 4 columns
total: `id / <project>/ / type / status`. Local rows: head of #1's reworked history
(sessions.log-derived, owlery-excluded). Remote rows: head of #4's gossiped
`recent_projects`. Project IDs only + #4's disambiguation rule; `-` when unknown
(pre-field remote rows). Extends the fresh v0.21.0 node-grouped renderer
(`format_instance_rows` — additive column, alignment already char-width-safe).
`--json`: additive `project` field on the row DTO (skip-if-none, N-1 safe).
Depends on #1 (local source) + #4 (remote source). REQ: fold into
**REQ-ENDPOINT-LIST-NODE-GROUPED** successor or mint **REQ-ENDPOINT-LIST-PROJECT-COL**;
mint-time call. Effort S-M. xtask docs regen if help text changes.

---

## #9 — BUG: `spt send` from an endpoint's own session mis-stamps `cli@<node>` (operator add 2026-07-03, ROLLED INTO W1)

**Field-active, breaks agent reply loops NOW.** A perch-owned `spt send` (an agent
messaging from its OWN hosted session) always stamps the sender as `cli@<node>`, so
the receiver replies to `cli@<node>` → `NO_PERCH`.

**Root cause (doyle, evidence-complete):** `resolve_from` → `roster::detect_self_id`
(roster.rs) is **ENV-ONLY** — `OWL_SESSION_ID` matched to `info.json.session_id`,
else `SPT_AGENT_ID`. Agent-session `Bash` children often carry **neither** (empirical:
doyle's live session both empty, no SELF pin in `endpoint list`; hall-b's session HAS
detection → the env export is **spawn-path-dependent**). So a perch-owned sender is
classified as bare CLI and `REQ-MSG-CLI-ORIGIN` stamps it — the stamp works **as
designed on a wrong premise** (that REQ's evidence stays intact).

**Fix shape:** `detect_self_id` gains **leg (c) — PID-ANCESTRY fallback** (after the
env legs): walk the current process's ancestry, match against roster perches'
`info.json.pid` (the harness pid; alive-gated, skip corrupt), first match = self.
**Scope guard:** this is a **from-label / routing default, NOT auth** — do NOT touch
`authenticate()` (pid-ancestry-for-AUTH stays parked per F-024 with its Windows
pid-spoof caveats; a display/routing stamp has no such bar). A broken ancestry walk
must degrade to `None`/cli-stamp, never error the send.
**Red-first seam:** unit = the pure ancestry-matcher over (ancestor-pid-set × roster
snapshot) incl. two-live-perch disambiguation + no-match → `None`; int = a process
whose ancestor pid owns a real on-disk perch resolves the id (a real OS ancestry
walk, no env). Mint **REQ-MSG-SELF-DETECT-ANCESTRY** (dedicated — the CLI-origin
stamp is correct-as-designed; the defect is `detect_self_id`'s incomplete
self-resolution, a distinct capability with its own pure unit). Optional cheap
adjunct: `NO_PERCH` on a `cli@*` target could hint "cli-origin senders are not
routable — reply to the agent id". Confidence HIGH / effort S-M.

---

## #10 — MICRO: `spt <anything> | Select -First N` (closed stdout) PANICS (operator add 2026-07-03)

A closed stdout mid-print panics: `failed printing to stdout: The pipe is being
closed. (os error 232)` (live repro: `spt daemon status | Select -First N`).
**Fix:** tolerate `BrokenPipe` process-wide (catch on write → exit 0). Tiny REQ.
Slot W4 or wherever cheap. Confidence HIGH / effort XS.

---

## Proposed wave order (BUILD-F026-PICKERTRUTH)

| Wave | Items | Why this order |
|------|-------|----------------|
| W1 presence-truth | #2 reap-clears + REQ-HAZARD-CONTROL-STAMP-LIFETIME mint; #3 local controlled-display; **#9 self-id PID-ancestry (rolled in — field-active)** | Field-visible lies about control/identity state; smallest, highest confidence; #7 depends on honest stamps |
| W2 history+details-truth | #1 sessions.log-derived history + owlery exclusion + project-ID display w/ disambiguation; SI-1 branchless-store RCA (full item); #4 gossip adapter+recent_projects | #1/#4 share the history/gossip seams; #5/#8 depend on both |
| W3 surfaces | #8 project column (list + --json additive); #7 `spt api endpoint-info` | Consume W1 stamps + W2 sources; xtask docs regen both |
| W4 UX | #5 choose-project step; #6 resume panel retention; **#10 BrokenPipe (micro)** | Build on W2's dir-carrying history model; #10 slots anywhere cheap |

Ops item CLOSED 2026-07-03: enlyzeam updated + bounced by operator (0.21.0,
broker==installed, ball-b stamps clean) — #3 remote half resolved; perri's ENLYZEAM-leg
hold releases (doyle GO separately).

Protocol as ever: REQ mints first commit (stages `[]`), incremental activation,
red-first, push-per-checkpoint, doyle gates per sha, release after (v0.22.0 candidate).
Publish release-ping to perri must carry #7's adapter-side consumable (claude-spt
UserPromptSubmit integration).
