Harness-contract inbound surface (hook entry points).

The entry points a harness's hooks fire to keep spt-core's on-disk state in sync.

Usage: spt.exe api [OPTIONS] --adapter <ADAPTER> <COMMAND>

Commands:
  seed            Harness-hosted startup: record an ephemeral seed keyed by parent pid
  listen          Consume a seed and hold the perch + relay loop (blocks)
  bind            Post-spawn bind of a session to its perch
  bind-shell      Shell-binary bind: the type=Shell flavor of `bind`. Resolves the instance **by
                  link token alone** (the spawn template carries only `{link_token}` — "owner from
                  the link") and flips it online. The credential IS the auth: no token, no bind
  state           Set activity state busy|idle (also arms the echo-gate sentinel)
  echo-gate       Manage the echo-gate sentinel directly
  poll            Drain delivered messages (hook channel). With `--link` this is the shell-flavored
                  relay drain: the link token is the auth, and the drained rows are the shell's
                  MAC-stamped command/text/file frames
  worker-start    Create a nested worker perch under a parent
  worker-stop     Tear down a worker perch
  worker-poll     Drain a worker perch's messages
  boundary        Rebind the perch to a new session_id, preserving identity (a context clear/compact
                  boundary)
  session-end     Soft teardown (spool/history preserved); `--erase` hard-wipes
  presence        Report user/agent presence at this endpoint
  driven-by       Report which node (if any) is remote-driving this endpoint
  history-log     Append normalized history (body on stdin) to the native history store
  emit            Emit a Shell sensory payload to the owner's **live** session. REST-only by
                  definition: never spooled, dropped with a diagnostic when the owner isn't live.
                  The link token is the auth
  capability      Print the adapter's declared capability (`hostable_types`)
  shutdown        Graceful live-agent signoff: run the final context save BEFORE teardown, then
                  soft-stop. The `spt shutdown` lifecycle path
  owner-shutdown  A shell suspends its linked owner directly, bypassing agent comms — gated by the
                  manifest `can_shutdown` pre-consent grant, fail-closed. The firing shell cascades
                  offline with its siblings, by design
  help            Print this message or the help of the given subcommand(s)

Options:
      --adapter <ADAPTER>


No `spt live` — deliberate. Live-agent **machinery** is in core; live-agent **UX** is harness-specific and lives in the adapter. The boundary (CONTEXT.md, ADR glue-model):

**What core ships today (the `/spt:live` equivalent, decomposed):**
- `spt api seed` + `api listen` — harness-hosted startup: seed record keyed by parent pid, then listen consumes it and becomes the long-running relay. This is the direct heir of `$LIVE start` behind the Monitor tool.
- `api bind` / `boundary` / `state` / `shutdown` — skeleton→live bind, /clear rebind, busy/idle, graceful signoff.
- `spt-live` crate (M2b): spawn-psyche, echo-commune, history briefs, signoff ordering invariants — hosted by the `api listen` runner.

**What's missing on purpose:** the part of `/spt:live` that's Claude Code convention — slash command, session resume semantics, Monitor wiring, `$LIVE`/`$OWL` env injection, id-picker UX. That's the **spt-claude-code adapter** (own repo, downstream), which wraps these api calls. CONTEXT.md sketches the eventual wrapped shape: `spt.exe --manifest spt-plugin.toml live start <id>` — adapter-invoked, not a core user verb.

Parity point: claude_skill_owl's `/spt:live` ≈ spt-claude-code skill → `spt api seed`/`listen` + lifecycle seams. That adapter is the next milestone after M7 closes (M7 ran first precisely so its CLI breakage landed before any adapter depended on the surface).

If you want a harness-agnostic `spt live start` verb in core someday — possible (machinery's all there), but it'd need the no-Claude-conventions rule respected. One for spt-claude-code scoping.


<task-notification>
<task-id>bodx1uxzx</task-id>
<tool-use-id>toolu_01BdcmvFxFY39MfM43Uvq2wj</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bodx1uxzx.output</output-file>
<status>completed</status>
<summary>Background command "Watch fix run to completion" completed (exit code 0)</summary>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


1	  - Dormancy budget (real adapter seats, when installed) — linux
2	  - Dormancy budget (real adapter seats, when installed) — windows
3	  ✓ Post Checkout spt-shell-notify (real-shell dogfood)
4	  ✓ Post Run actions/checkout@v5
5	  ✓ Complete job
6	✓ twohost-a in 3m23s (ID 79858432230)
7	  ✓ Set up job
8	  ✓ Run actions/checkout@v5
9	  ✓ Checkout spt-shell-notify (toast rung)
10	  ✓ Build notify-shell
11	  ✓ Build spt binary (toast rung)
12	  ✓ Two-host ladder — role A (joiner/driver)
13	  ✓ Post Checkout spt-shell-notify (toast rung)
14	  ✓ Post Run actions/checkout@v5
15	  ✓ Complete job
16	✓ twohost-b in 3m10s (ID 79858432235)
17	  ✓ Set up job
18	  ✓ Run actions/checkout@v5
19	  ✓ Checkout spt-shell-notify (toast rung)
20	  ✓ Build notify-shell
21	  ✓ Build spt binary (toast rung)
22	  ✓ Two-host ladder — role B (seed-holder/server)
23	  ✓ Post Checkout spt-shell-notify (toast rung)
24	  ✓ Post Run actions/checkout@v5
25	  ✓ Complete job
26

  - Dormancy budget (real adapter seats, when installed) — linux
  - Dormancy budget (real adapter seats, when installed) — windows
  ✓ Post Checkout spt-shell-notify (real-shell dogfood)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job
✓ twohost-a in 3m23s (ID 79858432230)
  ✓ Set up job
  ✓ Run actions/checkout@v5
  ✓ Checkout spt-shell-notify (toast rung)
  ✓ Build notify-shell
  ✓ Build spt binary (toast rung)
  ✓ Two-host ladder — role A (joiner/driver)
  ✓ Post Checkout spt-shell-notify (toast rung)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job
✓ twohost-b in 3m10s (ID 79858432235)
  ✓ Set up job
  ✓ Run actions/checkout@v5
  ✓ Checkout spt-shell-notify (toast rung)
  ✓ Build notify-shell
  ✓ Build spt binary (toast rung)
  ✓ Two-host ladder — role B (seed-holder/server)
  ✓ Post Checkout spt-shell-notify (toast rung)
  ✓ Post Run actions/checkout@v5
  ✓ Complete job


Bash

gh run view 27055141161 2>&1 | head -10; echo ---; gh run view --job=79858432230 --log 2>&1 | grep -aE "TWOHOST OK: pairing" | head -3

Confirm full run + pairing rung evidence


✓ dev-freeform ci · 27055141161
Triggered via push about 11 minutes ago

JOBS
✓ traceability in 11s (ID 79857989099)
✓ test (self-hosted, Windows, hfenduleam) in 4m55s (ID 79857989100)
✓ test (self-hosted, Linux, gravity) in 7m42s (ID 79857989102)
✓ twohost-a in 3m23s (ID 79858432230)
✓ twohost-b in 3m10s (ID 79858432235)
---
twohost-a	Two-host ladder — role A (joiner/driver)	2026-06-06T06:46:10.4314257Z TWOHOST OK: pairing (initiator, product surface) — Pinned


Write

M7 CI GREEN cross-host — run 27055141161 (fix 7a22a63): twohost ladder back at baseline (a 3m23s / b 3m10s), product-surface join rung proven: "TWOHOST OK: pairing (initiator, product surface) — Pinned" on first attempt.

Close-race postmortem (recorded for posterity): first rung run failed deterministically — responder dropped conn the instant run_responder returned; QUIC CLOSE discarded unacked Done+Seed frames; joiner read "connection lost", never persisted seed, retried into AlreadyTrusted until rig window. Fix = responder waits for JOINER to close (meet::serve_once discipline), CLOSE_GRACE 10s bound, limiter slot released pre-wait. Regression test spt-daemon/tests/pairjoin.rs (loopback joiner vs stock NetHost responder) verified fail-pre-fix/pass-post-fix.

M7 remaining to CLOSE:
1. Acceptance #1 manual UAT: fresh two-machine setup via literal `spt subnet create`+`join` CLI (elevated terminals, rendezvous — NOT the rig's explicit-addr seam), `status --nodes` both sides. Needs user at two machines.
2. Acceptance #2 manual UAT: quickstart prompt blocks pasted into two real agent sessions.
3. Closeout: M7-PLAN.md status flip, ROADMAP tick, possible docs-publish dispatch.

Also answered user q this session: no `spt live` by design — live UX is adapter-side (spt-claude-code, next milestone); core ships api seed/listen/bind/boundary/state/shutdown + spt-live crate machinery.


while we wait for that, a question: i notice there's no `spt live` command. what is the equivalent? (talking about parity with claude_skill_owl)


File created successfully at: C:\Users\decid\Documents\projects\spt-core\.claude\doyle-commune.md (file state is current in your context — no need to Read it back)

M7 CI GREEN cross-host — run 27055141161 (fix 7a22a63): twohost ladder back at baseline (a 3m23s / b 3m10s), product-surface join rung proven: "TWOHOST OK: pairing (initiator, product surface) — Pinned" on first attempt.

Close-race postmortem (recorded for posterity): first rung run failed deterministically — responder dropped conn the instant run_responder returned; QUIC CLOSE discarded unacked Done+Seed frames; joiner read "connection lost", never persisted seed, retried into AlreadyTrusted until rig window. Fix = responder waits for JOINER to close (meet::serve_once discipline), CLOSE_GRACE 10s bound, limiter slot released pre-wait. Regression test spt-daemon/tests/pairjoin.rs (loopback joiner vs stock NetHost responder) verified fail-pre-fix/pass-post-fix.

M7 remaining to CLOSE:
1. Acceptance #1 manual UAT: fresh two-machine setup via literal `spt subnet create`+`join` CLI (elevated terminals, rendezvous — NOT the rig's explicit-addr seam), `status --nodes` both sides. Needs user at two machines.
2. Acceptance #2 manual UAT: quickstart prompt blocks pasted into two real agent sessions.
3. Closeout: M7-PLAN.md status flip, ROADMAP tick, possible docs-publish dispatch.

Also answered user q this session: no `spt live` by design — live UX is adapter-side (spt-claude-code, next milestone); core ships api seed/listen/bind/boundary/state/shutdown + spt-live crate machinery.


Communed. M7 board:

| Item | State |
|---|---|
| D1-D5 code + docs | ✅ shipped |
| Cross-host CI (incl. join rung) | ✅ green |
| Acceptance #1 — real two-machine `create`/`join` via literal CLI (elevated, rendezvous path, `status --nodes` both sides) | ⬜ needs you at two machines |
| Acceptance #2 — quickstart prompt blocks in two real agent sessions | ⬜ manual UAT |
| Closeout — M7-PLAN status flip + ROADMAP tick | ⬜ after acceptance |

(Stale alarm fires in ~5 min — will ignore.) Say word when you want to run acceptance, or I prep closeout text now.


<task-notification>
<task-id>bi7aiqnpv</task-id>
<summary>Monitor event: "« spt event »"</summary>
<event><EVENT-PART seq="1/2" id="fab4c704" type="alarm" target-time="2026-06-05T23:56:26-07:00" current-time="2026-06-05T23:56:26-07:00">CI line: fix run 27055141161 (close-race fix 7a22a63) should be fully done incl. twohost ladder (~3.5m baseline post-fix). If twohost-a/b failed or still running: pull logs, check whether Done/Seed close race is truly gone (PAIRED once + role A 'produ</EVENT-PART>
<EVENT-PART seq="2/2" id="fab4c704">ct surface' OK line) or a second failure mode surfaced (meet rotation, CLOSE_GRACE shed, register rung).</EVENT-PART></event>
</task-notification>


Alarm stale — run 27055141161 already confirmed fully green (join rung `Pinned`, ladder at baseline). No action. Idle, awaiting your call on acceptance UAT / closeout.


how do i install the latest build on two machines if it isn't deployed?


Bash

grep -n "fn spt_home" -A12 crates/spt-store/src/perch.rs | head -18; grep -rn "release" .github/workflows/*.yml | grep -iE "cargo build|upload-artifact" | head

Check spt home location + CI artifact builds


34:pub fn spt_home() -> PathBuf {
35-    if let Ok(over) = std::env::var("SPT_HOME") {
36-        if !over.is_empty() {
37-            return PathBuf::from(over);
38-        }
39-    }
40-    platform_default_spt_home()
41-}
42-
43-#[cfg(windows)]
44-fn platform_default_spt_home() -> PathBuf {
45-    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46-        if !local.is_empty() {
--
550:    fn spt_home_is_pure_and_honors_env() {
551-        let tmp = tempfile::tempdir().unwrap();
552-        let target = tmp.path().join("does-not-exist-yet");
553-        let _g = EnvGuard::set(&target);
.github/workflows/ci.yml:220:            cargo build --release --manifest-path "$SRC/rust/Cargo.toml"
.github/workflows/release.yml:40:        run: cargo build --release -p spt --bin spt


No deploy needed — both machines are CI runners with the repo + toolchain. Build from source:

**This box (HFENDULEAM, Windows):**
```powershell
git pull
cargo build --release -p spt
# binary: target\release\spt.exe
```

**gravity (Linux):** same two lines in its clone (or the runner workdir) → `target/release/spt`.

**Then the acceptance run (elevated terminals both sides):**
```sh
# machine 1 (admin/sudo):
spt subnet create <name>        # prints code + QR

# machine 2 (admin/sudo):
spt subnet join <name>          # prompts for code; LAN+relay rendezvous

# both sides:
spt subnet status --nodes
```

Two notes:

1. **Fresh home recommended** — acceptance #1 says "fresh two-machine setup". Default home is `%LOCALAPPDATA%`-based (Linux: XDG equivalent); set `SPT_HOME` to a fresh dir in each elevated terminal to keep the UAT clean and avoid touching any existing spt-core state. Caveat: `SPT_HOME` must be set for *every* `spt` invocation in that flow (daemon auto-start inherits it from the invoking command).
2. Use the **absolute path** to `target\release\spt.exe` (or alias it) — don't overwrite the installed v0.1.0 binary; this is a dev build.

Daemon auto-starts on first command needing it — no service setup. Same LAN: mDNS finds the meet listener; different networks: default n0 relay carries it.


please build and move the binary to its actual install dir on both machines. ssh for gravity-linux is uname `david`, pw `bigscreen`


grep -nE "install_dir|dest|bin|PATH" installer/install.ps1 2>/dev/null | head -8; echo ---; grep -nE "INSTALL_DIR|dest|local/bin" installer/install.sh 2>/dev/null | head -8; ls installer 2>/dev/null

Find installer target dirs


7:# never prompt. It downloads the latest release binary, sha256-checks it
10:# the per-OS install root, and registers the *user* PATH.
16:#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)
21:#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
36:$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }
49:    # ---- fetch: binary + SHA256SUMS ----------------------------------------
51:    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
52:    function Fetch([string]$name, [string]$dest) {
---
14:#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/bin)
23:INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"
51:fetch() { # $1 = file name, $2 = dest path
78:mkdir -p "$INSTALL_DIR"
80:mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"
86:    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
91:        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
92:        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
install.ps1
install.sh


TaskCreate

Deploy dev build to install dirs on HFENDULEAM + gravity

Build release locally → %LOCALAPPDATA%\spt\bin\spt.exe (stop running daemon if locking). ssh gravity (david): pull repo, cargo build --release, → ~/.local/bin/spt.


Task #14 created successfully: Deploy dev build to install dirs on HFENDULEAM + gravity


Bash

cargo build --release -p spt 2>&1 | tail -2

Build local release binary


Command running in background with ID: bxips2181. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bxips2181.output. You will be notified when it completes. To check interim output, use Read on that file path.


ssh -o BatchMode=yes -o ConnectTimeout=5 david@gravity "echo OK; hostname" 2>&1 | head -3

Test passwordless ssh to gravity


ssh: Could not resolve hostname gravity: Name or service not known


grep -nE "PEER_IP|gravity|192\.|10\.|172\." .github/workflows/ci.yml | head

Find gravity IP in CI workflow


18:  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
27:        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
141:    runs-on: [self-hosted, Linux, gravity]
166:          SPT_TWO_HOST_PEER_IP: "100.68.35.65" # hfenduleam (tailscale)
191:          SPT_TWO_HOST_PEER_IP: "100.100.62.43" # gravity-linux (tailscale)
197:    runs-on: [self-hosted, Linux, gravity]
202:        # Source-pinned, self-healing (M6-D2): gravity cannot run the release
203:        # asset (it needs GLIBC 2.39; gravity's Ubuntu 22.04 has 2.35), and we


ssh -o BatchMode=yes -o ConnectTimeout=5 david@100.100.62.43 "echo OK" 2>&1 | head -2; which sshpass plink putty 2>&1 | head -3

Test key auth to gravity IP + find password-auth tools


OK
which: no sshpass in (/c/Users/decid/bin:/mingw64/bin:/usr/local/bin:/usr/bin:/bin:/mingw64/bin:/usr/bin:/c/Users/decid/bin:/c/Program Files/PowerShell/7:/c/Program Files/Eclipse Adoptium/jdk-17.0.19.10-hotspot/bin:/c/Program Files/PlasticSCM5/server:/c/Program Files/PlasticSCM5/client:/c/program files/python312/scripts:/c/program files/python312:/c/program files/eclipse adoptium/jre-8.0.392.8-hotspot/bin:/c/WINDOWS/system32:/c/WINDOWS:/c/WINDOWS/System32/Wbem:/c/WINDOWS/system32/windowspowershell/v1.0:/c/WINDOWS/system32/openssh:/c/program files/putty:/c/program files/microsoft vs code/bin:/c/program files/dotnet:/c/programdata/chocolatey/bin:/c/program files/git/cmd:/c/Program Files/Microsoft VS Code/bin:/c/Program Files/NVIDIA Corporation/NVIDIA app/NvDLISR:/c/WINDOWS/system32:/c/WINDOWS:/c/WINDOWS/System32/Wbem:/c/WINDOWS/System32/WindowsPowerShell/v1.0:/c/WINDOWS/System32/OpenSSH:/c/Program Files (x86)/NVIDIA Corporation/PhysX/Common:/c/Program Files (x86)/Windows Kits/10/Windows Performance Toolkit:/c/Program Files/USBip:/c/Program Files/PowerToys/DSCModules:/c/Program Files/PowerShell/7:/c/Users/decid/AppData/Local/nvm:/c/nvm4w/nodejs:/c/Program Files/GitHub CLI:/c/Users/decid/.local/bin:/c/dev/flutter/bin:/c/Program Files/Tailscale:/c/Users/decid/AppData/Roaming/Python/Python310/Scripts::/Users/decid/AppData/Roaming/Python/Python312/Scripts:/c/Program Files/CMake/bin:/c/Program Files/Arduino CLI:/c/Users/decid/AppData/Local/Programs/traceable-reqs:/c/Users/decid/.cargo/bin:/c/Users/decid/scoop/apps/mpv/current:/c/Users/decid/scoop/shims:/c/Users/decid/AppData/Local/Microsoft/WindowsApps:/c/Users/decid/AppData/Local/GitHubDesktop/bin:/c/Users/decid/.dotnet/tools:/c/Users/decid/AppData/Local/Microsoft/WindowsApps:/c/Users/decid/AppData/Local/nvm:/c/nvm4w/nodejs:/c/Users/decid/AppData/Local/Microsoft/WinGet/Links:/c/Users/decid/AppData/Local/Microsoft/WinGet/Packages/ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe:/c/Users/decid/.fly/bin:/usr/bin/vendor_perl:/usr/bin/core_perl:/c/Users/decid/.claude/plugins/cache/claude-plugins-official/frontend-design/unknown/bin:/c/Users/decid/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0/bin:/c/Users/decid/.claude/plugins/cache/cplugs/screen-timelapse/1.2.2/bin:/c/Users/decid/.claude/plugins/cache/cplugs/spt/1.11.25/bin:/c/Users/decid/.claude/plugins/cache/caveman/caveman/84cc3c14fa1e/bin:/c/Users/decid/.claude/plugins/cache/cplugs/everything/0.2.0/bin)
/c/program files/putty/plink
/c/program files/putty/putty


Key auth already set up — password not needed. Gravity recon:


ssh david@100.100.62.43 'ls -d ~/spt-core ~/actions-runner/_work/spt-core/spt-core 2>/dev/null; ls ~/.local/bin/spt 2>/dev/null && ~/.local/bin/spt --version 2>/dev/null; which cargo || ls ~/.cargo/bin/cargo' 2>&1

Locate repo clone + install dir + toolchain on gravity


/home/david/actions-runner/_work/spt-core/spt-core
/home/david/.cargo/bin/cargo


ssh david@100.100.62.43 'cd ~/actions-runner/_work/spt-core/spt-core && git log -1 --oneline && export PATH=$HOME/.cargo/bin:$PATH && nohup cargo build --release -p spt > /tmp/spt-release-build.log 2>&1 & echo started' 2>&1

Verify gravity checkout rev + start release build


<task-notification>
<task-id>bxips2181</task-id>
<tool-use-id>toolu_01LJwgbx9twfmxHMAhTNakQo</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bxips2181.output</output-file>
<status>completed</status>
<summary>Background command "Build local release binary" completed (exit code 0)</summary>
</task-notification>


Command running in background with ID: brrn4p2bv. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\brrn4p2bv.output. You will be notified when it completes. To check interim output, use Read on that file path.


Prompt:
<task-notification>
<task-id>bxips2181</task-id>
<tool-use-id>toolu_01LJwgbx9twfmxHMAhTNakQo</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bxips2181.output</output-file>
<status>completed</status>
<summary>Background command "Build local release binary" completed (exit code 0)</summary>
</task-notification>


1	   Compiling spt v0.1.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
2	    Finished `release` profile [optimized] target(s) in 1m 02s
3

   Compiling spt v0.1.0 (C:\Users\decid\Documents\projects\spt-core\crates\spt)
    Finished `release` profile [optimized] target(s) in 1m 02s


Local release built. Install locally:


PowerShell

$dst = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'; if (Test-Path $dst) { & $dst --version } else { "not installed yet" }; Get-Process spt -ErrorAction SilentlyContinue | Select-Object Id, Path | Format-Table -AutoSize

Check installed spt + running processes


spt 0.1.0


Build release locally → %LOCALAPPDATA%\spt\bin\spt.exe (stop running daemon if locking). ssh gravity (david): pull repo, cargo build --release, → ~/.local/bin/spt.


Updated task #14 status


$dst = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'; Copy-Item target\release\spt.exe $dst -Force; & $dst subnet --help | Select-Object -First 4; & $dst --version

Install dev binary over local install dir + verify subnet verb


Subnet membership: status, create, show-totp.

A subnet is a private group of paired machines — your agents reach each other across every member
node. Bare `spt subnet` shows the membership status view.
spt 0.1.0


HFENDULEAM ✓ — installed `spt.exe` now answers `subnet` (dev build in place). Gravity build status:


Bash

ssh david@100.100.62.43 'tail -2 /tmp/spt-release-build.log; ls -la ~/actions-runner/_work/spt-core/spt-core/target/release/spt 2>/dev/null' 2>&1

Check gravity build progress


Error: Exit code 2
   Compiling ref-cast-impl v1.0.25
   Compiling simple-dns v0.9.3


Still compiling (fresh release profile). Background installer — waits for build, then places binary:


ssh david@100.100.62.43 'cd ~/actions-runner/_work/spt-core/spt-core; for i in $(seq 1 120); do if grep -q "^error" /tmp/spt-release-build.log; then echo BUILD_FAILED; tail -5 /tmp/spt-release-build.log; exit 1; fi; if grep -q "Finished \`release\`" /tmp/spt-release-build.log; then break; fi; sleep 10; done; mkdir -p ~/.local/bin && cp target/release/spt ~/.local/bin/spt && echo INSTALLED && ~/.local/bin/spt subnet --help | head -1 && git log -1 --oneline' 2>&1

Wait for gravity build, install to ~/.local/bin


Command running in background with ID: bra345vuq. Output is being written to: C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bra345vuq.output. You will be notified when it completes. To check interim output, use Read on that file path.


State:

- **HFENDULEAM ✓** — dev build at `%LOCALAPPDATA%\spt\bin\spt.exe`, `subnet` verb confirmed, no running daemon was locking it.
- **gravity ⏳** — release build running in runner workdir (checked out at the green CI commit); installer task (bra345vuq) waits for `Finished`, copies to `~/.local/bin/spt`, prints checkout rev for confirmation.

Will report when gravity lands. Note: ssh key auth was already in place — password unused.


<task-notification>
<task-id>brrn4p2bv</task-id>
<tool-use-id>toolu_01HTMuNAXwoHVENKspTaHMH4</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\brrn4p2bv.output</output-file>
<status>completed</status>
<summary>Background command "Verify gravity checkout rev + start release build" completed (exit code 0)</summary>
</task-notification>


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


<task-notification>
<task-id>bra345vuq</task-id>
<tool-use-id>toolu_018Guv2ZJWgyFC9MWtyaqx7J</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bra345vuq.output</output-file>
<status>completed</status>
<summary>Background command "Wait for gravity build, install to ~/.local/bin" completed (exit code 0)</summary>
</task-notification>


1	started
2	7a22a63 fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)
3

started
7a22a63 fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)


Prompt:
<task-notification>
<task-id>bra345vuq</task-id>
<tool-use-id>toolu_018Guv2ZJWgyFC9MWtyaqx7J</tool-use-id>
<output-file>C:\Users\decid\AppData\Local\Temp\claude\C--Users-decid-Documents-projects-spt-core\648159b6-16b2-4568-b643-7b0c98c1871d\tasks\bra345vuq.output</output-file>
<status>completed</status>
<summary>Background command "Wait for gravity build, install to ~/.local/bin" completed (exit code 0)</summary>
</task-notification>


1	INSTALLED
2	Subnet membership: status, create, show-totp.
3	7a22a63 fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)
4

INSTALLED
Subnet membership: status, create, show-totp.
7a22a63 fix(daemon): M7 [twohost] — responder must let the joiner close first (Done/Seed close race)


**Both machines deployed**, same commit 7a22a63 (the CI-green build):

- HFENDULEAM: `%LOCALAPPDATA%\spt\bin\spt.exe` ✓ (`subnet` verb answers)
- gravity: `~/.local/bin/spt` ✓ (`subnet` verb answers)

Acceptance #1 ready. Your steps (elevation = the part only you can do):

```powershell
# HFENDULEAM — admin terminal:
spt subnet create <name>     # prints code + QR + join hint
spt subnet status --nodes
```

```sh
# gravity — sudo:
sudo ~/.local/bin/spt subnet join <name>    # prompts for code
~/.local/bin/spt subnet status --nodes
```

Expected: gravity finds HFENDULEAM via LAN/relay rendezvous, `JOINED:<name>`, then both `status --nodes` show two labeled nodes online. Caveats:

- gravity's `~/.local/bin` may not be on root's PATH — hence absolute path under `sudo`.
- `sudo` runs as root → root's `SPT_HOME` differs from david's. Subnet store lands in root's home; david's later `status` won't see it. If that bites, run join as `sudo -E` won't fix HOME — better: `sudo SPT_HOME=/home/david/.local/share/spt <path> subnet join <name>` or accept root-owned state for the UAT. Flag what you see — that UX wrinkle is itself acceptance signal (decision 9 gates were designed knowing this).

Report outputs back; I'll log acceptance verdict + close M7.


shouldn't the binary be at %LOCALAPPDATA\**spot-core**\bin\?


shouldn't the binary be at %LOCALAPPDATA\**spt-core**\bin\?


sed -n 43,60p crates/spt-store/src/perch.rs; ls "$LOCALAPPDATA" 2>/dev/null | grep -i "^spt" ; ls "$LOCALAPPDATA/spt" "$LOCALAPPDATA/spt-core" 2>&1 | head

Check home default + actual dirs on disk


#[cfg(windows)]
fn platform_default_spt_home() -> PathBuf {
    if let Ok(local) = std::env::var("LOCALAPPDATA") {
        if !local.is_empty() {
            return PathBuf::from(local).join("spt-core");
        }
    }
    let up = std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
    PathBuf::from(up).join("AppData").join("Local").join("spt-core")
}

#[cfg(unix)]
fn platform_default_spt_home() -> PathBuf {
    let home = std::env::var("HOME").expect("HOME must be set on Unix");
    PathBuf::from(home).join(".spt-core")
}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
spt
spt-core
C:\Users\decid\AppData\Local/spt:
bin
last-seen-version.json
logs_latest
owlery
psyches
pulses
settings.json
settings.json.bak-20260526233508
settings.json.bak-syncclean-20260527030355


Two different dirs, easy to conflate:

| What | Where | Source |
|---|---|---|
| **Binary** (installer) | `%LOCALAPPDATA%\spt\bin\spt.exe` | `install.ps1:36` — shipped in v0.1.0 |
| **State** (SPT_HOME default) | `%LOCALAPPDATA%\spt-core\` | `perch.rs::platform_default_spt_home` |

I copied to where the v0.1.0 installer actually puts it — overwrote the existing registered-on-PATH binary there. So the deploy is right *per the shipped installer*.

But your instinct flags something real: `%LOCALAPPDATA%\spt\` is **also claude_skill_owl's live home** (owlery/, psyches/, pulses/ sitting right next to bin/ — that listing is the sister project's state). v0.1.0's installer chose to cohabit its bin/ inside it. No functional clash today (bin/ is its own subdir), but it's smelly:

- state dir was deliberately suffixed `spt-core` to avoid exactly this collision
- install dir wasn't given the same treatment

Changing it now = migration question (existing installs have `spt\bin` on user PATH; installer would need move-old-binary + re-register PATH). Not a unilateral fix — candidate for the backlog/next milestone alongside the OS-service work. Want me to log it as a captured issue?


address it now. needs to be clean in time for adoption.


grep -rn "spt\\\\bin\|spt/bin\|'spt\\\\bin'\|spt\\\\\\\\bin" --include="*.ps1" --include="*.sh" --include="*.md" --include="*.rs" . 2>/dev/null | grep -v target | grep -vi "spt-core" | head; echo ---; grep -n "install" traceable-reqs.toml | head -8

Find old-path references + installer REQ


./docs-site/src/harness-contract/install-on-demand.md:52:    $spt = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'
./docs-site/src/harness-contract/install-on-demand.md:66:(`%LOCALAPPDATA%\spt\bin\spt.exe`; the installer prints it). Every new
./docs-site/src/reference/install.md:23:   `%LOCALAPPDATA%\spt\bin`).
./installer/install.ps1:16:#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)
./installer/install.ps1:36:$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }
---
10:# installed CLI at M0 (the binary isn't installed yet).
14:# carries doc-stage tags (e.g. §Installation). installer/ added M6-D2.
15:roots = ["src", "tests", "docs", "crates", "installer", "CONTEXT.md", "docs-site", ".github"]
381:title = "Two install paths; signed one-line script; OS-service registration"
382:required_stages = ["doc", "impl", "int"]  # activated M6-D2: installer/install.sh + install.ps1 — the standalone one-line script half (latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → absolute-path print); the harness-bootstrapped half (path a) calls into the same script by design (CONTEXT §Installation). int = oneliner_e2e.rs staged-release rung on both runners. OS-service registration EXCLUDED → docs/DEFERRED.md (daemon auto-start covers dev-stage); "signed" = sha256 at first fetch, full ed25519 is `spt update`'s job (grill decision 3)
385:title = "Marketplace-repackaging-friendly install"
386:required_stages = ["doc"]  # doc activated M6-D2: the relocatable-binary + minimal non-OS-entangled install-logic stance documented (CONTEXT §Installation tag) — the scripts are that stance (single static binary, no OS service, user-scope PATH only); actual marketplace repackaging stays doc-stage per M6-PLAN §Out
393:title = "Adapter registration lifecycle: spt adapter add (--github, manifest-first, install-is-first-update) + soft-deregister remove + optional manifest uninstall template; node-local registered-adapter set self-update ripples over"


grep -n "REQ" traceable-reqs.toml | sed -n '/381/,/383/p'; sed -n 378,384p traceable-reqs.toml; ls crates/spt/tests/ | grep -i one

Get installer REQ id + e2e test name


# ───────────────────────────── Install / migration / infra ──────────────────
[[requirements]]
id = "REQ-INSTALL-1"
title = "Two install paths; signed one-line script; OS-service registration"
required_stages = ["doc", "impl", "int"]  # activated M6-D2: installer/install.sh + install.ps1 — the standalone one-line script half (latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → absolute-path print); the harness-bootstrapped half (path a) calls into the same script by design (CONTEXT §Installation). int = oneliner_e2e.rs staged-release rung on both runners. OS-service registration EXCLUDED → docs/DEFERRED.md (daemon auto-start covers dev-stage); "signed" = sha256 at first fetch, full ed25519 is `spt update`'s job (grill decision 3)
[[requirements]]
id = "REQ-INSTALL-2"
oneliner_e2e.rs


1	# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
2	#
3	#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
4	#
5	# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
6	# every adapter's pack-in on-demand install (no second mechanism), so it must
7	# never prompt. It downloads the latest release binary, sha256-checks it
8	# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
9	# job after first install; first-fetch trust = HTTPS + GitHub), places it under
10	# the per-OS install root, and registers the *user* PATH.
11	#
12	# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
13	# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
14	#
15	# Environment knobs (all optional):
16	#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)
17	#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
18	#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
19	#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
20	#                          directly (bypasses the release lookup; CI / air-gap)
21	#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration
22
23	$ErrorActionPreference = 'Stop'
24	$ProgressPreference = 'SilentlyContinue'
25	# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
26	[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
27
28	# throw, never `exit`: under `irm | iex` the script body runs in the user's
29	# session, where `exit` would close their terminal. A throw aborts the install
30	# either way, and `powershell -File` still maps it to a nonzero exit code.
31	function Fail([string]$msg) {
32	    throw "spt-install: error: $msg"
33	}
34
35	$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
36	$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }
37
38	# ---- platform / arch -> release asset name -------------------------------
39	$arch = $env:PROCESSOR_ARCHITECTURE
40	if ($arch -ne 'AMD64') {
41	    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
42	}
43	$asset = 'spt-x86_64-windows.exe'
44
45	$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
46	New-Item -ItemType Directory -Path $work -Force | Out-Null
47
48	try {
49	    # ---- fetch: binary + SHA256SUMS ----------------------------------------
50	    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
51	    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
52	    function Fetch([string]$name, [string]$dest) {
53	        $base = $env:SPT_INSTALL_ASSET_BASE
54	        if ($base) {
55	            if ($base -match '^https?://') {
56	                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
57	            } else {
58	                Copy-Item -Path (Join-Path $base $name) -Destination $dest
59	            }
60	        } elseif ($env:SPT_INSTALL_VERSION) {
61	            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
62	        } else {
63	            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
64	        }
65	    }
66
67	    Write-Output "spt-install: fetching $asset ..."
68	    $binPath = Join-Path $work $asset
69	    $sumsPath = Join-Path $work 'SHA256SUMS'
70	    Fetch $asset $binPath
71	    Fetch 'SHA256SUMS' $sumsPath
72
73	    # ---- verify -------------------------------------------------------------
74	    $want = $null
75	    foreach ($line in (Get-Content $sumsPath)) {
76	        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
77	            $want = $Matches[1].ToLowerInvariant()
78	        }
79	    }
80	    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
81	    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
82	    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
83	    $sha = [Security.Cryptography.SHA256]::Create()
84	    $stream = [IO.File]::OpenRead($binPath)
85	    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
86	    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
87	    if ($got -ne $want) {
88	        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
89	    }
90	    Write-Output "spt-install: sha256 verified."
91
92	    # ---- place --------------------------------------------------------------
93	    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
94	    # the PATH registration below is added at most once.
95	    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
96	    $target = Join-Path $installDir 'spt.exe'
97	    try {
98	        Move-Item -Path $binPath -Destination $target -Force
99	    } catch {
100	        Fail "could not replace $target (is spt running? stop it and re-run): $_"
101	    }
102
103	    # ---- user PATH registration ----------------------------------------------
104	    $pathNote = $null
105	    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
106	        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
107	        if (-not $userPath) { $userPath = '' }
108	        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
109	        if ($entries -notcontains $installDir.TrimEnd('\')) {
110	            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
111	            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
112	            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
113	        }
114	    }
115
116	    # ---- report ---------------------------------------------------------------
117	    # Print the absolute path: the PATH change does not reach this terminal, so
118	    # the first invocation here must use it absolutely.
119	    Write-Output "spt-install: installed $target"
120	    & $target --version
121	    if ($pathNote) {
122	        Write-Output "spt-install: $pathNote"
123	        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
124	    }
125	} finally {
126	    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
127	}
128

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}


grep -n "SPT_INSTALL_DIR\|spt\\\\bin\|installDir" crates/spt/tests/oneliner_e2e.rs | head

Check e2e installer test dir handling


90:        .env("SPT_INSTALL_DIR", install_dir)


Edit

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)


The file C:\Users\decid\Documents\projects\spt-core\installer\install.ps1 has been updated successfully. (file state is current in your context — no need to Read it back)

# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

-#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt\bin)

+#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)


$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
# home was a v0.1.0 mistake; the migration block below cleans it up).
$usingDefaultDir = -not $env:SPT_INSTALL_DIR
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }


$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
# home was a v0.1.0 mistake; the migration block below cleans it up).
$usingDefaultDir = -not $env:SPT_INSTALL_DIR
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

-$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt\bin' }

+# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT

+# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its

+# home was a v0.1.0 mistake; the migration block below cleans it up).

+$usingDefaultDir = -not $env:SPT_INSTALL_DIR

+$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }


    # ---- migrate the v0.1.0 install dir ---------------------------------------
    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -
    # inside the LEGACY SPT plugin's state home. Re-running this installer
    # relocates cleanly: remove exactly the old binary + its bin dir (never
    # the parent - it holds the legacy plugin's live state) and drop the old
    # dir from the user PATH. Only fires on the default-dir path; an explicit
    # SPT_INSTALL_DIR opts out of any migration opinion.
    if ($usingDefaultDir) {
        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'
        $oldExe = Join-Path $oldDir 'spt.exe'
        if (Test-Path $oldExe) {
            try {
                Remove-Item -Path $oldExe -Force
                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {
                    Remove-Item -Path $oldDir -Force
                }
                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"
            } catch {
                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"
            }
        }
        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')
        if ($userPathNow) {
            $oldNorm = $oldDir.TrimEnd('\')
            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }
            $cleaned = $kept -join ';'
            if ($cleaned -ne $userPathNow) {
                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"
            }
        }
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null


    # ---- migrate the v0.1.0 install dir ---------------------------------------
    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -
    # inside the LEGACY SPT plugin's state home. Re-running this installer
    # relocates cleanly: remove exactly the old binary + its bin dir (never
    # the parent - it holds the legacy plugin's live state) and drop the old
    # dir from the user PATH. Only fires on the default-dir path; an explicit
    # SPT_INSTALL_DIR opts out of any migration opinion.
    if ($usingDefaultDir) {
        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'
        $oldExe = Join-Path $oldDir 'spt.exe'
        if (Test-Path $oldExe) {
            try {
                Remove-Item -Path $oldExe -Force
                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {
                    Remove-Item -Path $oldDir -Force
                }
                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"
            } catch {
                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"
            }
        }
        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')
        if ($userPathNow) {
            $oldNorm = $oldDir.TrimEnd('\')
            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }
            $cleaned = $kept -join ';'
            if ($cleaned -ne $userPathNow) {
                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')
                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"
            }
        }
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null

# spt installer (Windows) - served at the canonical Pages URL (ADR-0014):
#
#   irm https://sabermage.github.io/spt-releases/install.ps1 | iex
#
# [impl->REQ-INSTALL-5] Non-interactive BY CONSTRUCTION: this same script is
# every adapter's pack-in on-demand install (no second mechanism), so it must
# never prompt. It downloads the latest release binary, sha256-checks it
# against the release's SHA256SUMS (full ed25519 verification is `spt update`'s
# job after first install; first-fetch trust = HTTPS + GitHub), places it under
# the per-OS install root, and registers the *user* PATH.
#
# Compatible with stock Windows PowerShell 5.1 (the `irm | iex` default) and
# PowerShell 7+. ASCII only: PS 5.1 reads BOM-less scripts as ANSI.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir          (default: %LOCALAPPDATA%\spt-core\bin)
#   SPT_INSTALL_REPO       owner/repo           (default: SaberMage/spt-releases)
#   SPT_INSTALL_VERSION    release tag to pin   (default: latest)
#   SPT_INSTALL_ASSET_BASE URL or local dir holding the assets + SHA256SUMS
#                          directly (bypasses the release lookup; CI / air-gap)
#   SPT_INSTALL_NO_PATH    set to 1 to skip PATH registration

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# PS 5.1 defaults to TLS 1.0; GitHub requires >= 1.2.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# throw, never `exit`: under `irm | iex` the script body runs in the user's
# session, where `exit` would close their terminal. A throw aborts the install
# either way, and `powershell -File` still maps it to a nonzero exit code.
function Fail([string]$msg) {
    throw "spt-install: error: $msg"
}

$repo = if ($env:SPT_INSTALL_REPO) { $env:SPT_INSTALL_REPO } else { 'SaberMage/spt-releases' }
# spt-core\bin, matching the state home (%LOCALAPPDATA%\spt-core) - NOT
# %LOCALAPPDATA%\spt, which belongs to the legacy SPT plugin (cohabiting its
# home was a v0.1.0 mistake; the migration block below cleans it up).
$usingDefaultDir = -not $env:SPT_INSTALL_DIR
$installDir = if ($env:SPT_INSTALL_DIR) { $env:SPT_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'spt-core\bin' }

# ---- platform / arch -> release asset name -------------------------------
$arch = $env:PROCESSOR_ARCHITECTURE
if ($arch -ne 'AMD64') {
    Fail "no published build for architecture '$arch' yet (x86_64 only today)"
}
$asset = 'spt-x86_64-windows.exe'

$work = Join-Path ([IO.Path]::GetTempPath()) ("spt-install-" + [IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $work -Force | Out-Null

try {
    # ---- fetch: binary + SHA256SUMS ----------------------------------------
    # [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
    # lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
    function Fetch([string]$name, [string]$dest) {
        $base = $env:SPT_INSTALL_ASSET_BASE
        if ($base) {
            if ($base -match '^https?://') {
                Invoke-WebRequest -Uri "$base/$name" -OutFile $dest -UseBasicParsing
            } else {
                Copy-Item -Path (Join-Path $base $name) -Destination $dest
            }
        } elseif ($env:SPT_INSTALL_VERSION) {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/download/$($env:SPT_INSTALL_VERSION)/$name" -OutFile $dest -UseBasicParsing
        } else {
            Invoke-WebRequest -Uri "https://github.com/$repo/releases/latest/download/$name" -OutFile $dest -UseBasicParsing
        }
    }

    Write-Output "spt-install: fetching $asset ..."
    $binPath = Join-Path $work $asset
    $sumsPath = Join-Path $work 'SHA256SUMS'
    Fetch $asset $binPath
    Fetch 'SHA256SUMS' $sumsPath

    # ---- verify -------------------------------------------------------------
    $want = $null
    foreach ($line in (Get-Content $sumsPath)) {
        if ($line -match '^([0-9a-fA-F]{64})\s+\*?(.+)$' -and $Matches[2].Trim() -eq $asset) {
            $want = $Matches[1].ToLowerInvariant()
        }
    }
    if (-not $want) { Fail "SHA256SUMS has no entry for $asset" }
    # .NET directly, not Get-FileHash: cmdlet autoload breaks when a PS7
    # parent's PSModulePath leaks into a 5.1 child (and on hardened boxes).
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::OpenRead($binPath)
    try { $hashBytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() }
    $got = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
    if ($got -ne $want) {
        Fail "sha256 mismatch for $asset (expected $want, got $got) - refusing to install"
    }
    Write-Output "spt-install: sha256 verified."

    # ---- place --------------------------------------------------------------
    # [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced in place;
    # the PATH registration below is added at most once.
    New-Item -ItemType Directory -Path $installDir -Force | Out-Null
    $target = Join-Path $installDir 'spt.exe'
    try {
        Move-Item -Path $binPath -Destination $target -Force
    } catch {
        Fail "could not replace $target (is spt running? stop it and re-run): $_"
    }

    # ---- user PATH registration ----------------------------------------------
    $pathNote = $null
    if ($env:SPT_INSTALL_NO_PATH -ne '1') {
        $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
        if (-not $userPath) { $userPath = '' }
        $entries = $userPath -split ';' | ForEach-Object { $_.Trim().TrimEnd('\') } | Where-Object { $_ }
        if ($entries -notcontains $installDir.TrimEnd('\')) {
            $newPath = if ($userPath -and -not $userPath.EndsWith(';')) { "$userPath;$installDir" } else { "$userPath$installDir" }
            [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
            $pathNote = "Registered $installDir on your user PATH (takes effect in NEW terminals only)."
        }
    }

    # ---- report ---------------------------------------------------------------
    # Print the absolute path: the PATH change does not reach this terminal, so
    # the first invocation here must use it absolutely.
    Write-Output "spt-install: installed $target"
    & $target --version
    if ($pathNote) {
        Write-Output "spt-install: $pathNote"
        Write-Output "spt-install: in THIS terminal, invoke it as: $target"
    }
} finally {
    Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

+    # [impl->REQ-INSTALL-3] v0.1.0 installed into %LOCALAPPDATA%\spt\bin -

+    # inside the LEGACY SPT plugin's state home. Re-running this installer

+    # relocates cleanly: remove exactly the old binary + its bin dir (never

+    # the parent - it holds the legacy plugin's live state) and drop the old

+    # dir from the user PATH. Only fires on the default-dir path; an explicit

+    # SPT_INSTALL_DIR opts out of any migration opinion.

+        $oldDir = Join-Path $env:LOCALAPPDATA 'spt\bin'

+        $oldExe = Join-Path $oldDir 'spt.exe'

+                Remove-Item -Path $oldExe -Force

+                if (-not (Get-ChildItem -Path $oldDir -Force -ErrorAction SilentlyContinue)) {

+                    Remove-Item -Path $oldDir -Force

+                Write-Output "spt-install: migrated: removed v0.1.0 binary at $oldExe"

+                Write-Output "spt-install: warning: could not remove old install at $oldExe (is spt running?); remove it manually"

+        $userPathNow = [Environment]::GetEnvironmentVariable('Path', 'User')

+            $oldNorm = $oldDir.TrimEnd('\')

+            $kept = $userPathNow -split ';' | Where-Object { $_ -and ($_.Trim().TrimEnd('\') -ne $oldNorm) }

+            if ($cleaned -ne $userPathNow) {

+                [Environment]::SetEnvironmentVariable('Path', $cleaned, 'User')

+                Write-Output "spt-install: migrated: removed $oldDir from your user PATH"


Now the doc references:


Bash

sed -n 18,28p docs-site/src/reference/install.md; echo ===; sed -n 48,70p docs-site/src/harness-contract/install-on-demand.md

View doc sections to update


1. Resolve the latest release (or `SPT_INSTALL_VERSION`).
2. Download the platform binary and the release's `SHA256SUMS`.
3. **Verify the sha256** — a mismatch refuses before anything is placed.
4. Place the binary under the per-OS install root (`~/.local/bin` /
   `%LOCALAPPDATA%\spt\bin`).
5. Register the **user** PATH (at most once; idempotent re-runs).
6. Print the absolute installed path — on Windows the PATH change reaches
   **new** terminals only, so the first invocation in the current one uses
   that absolute path.

===
    Write-Output "spt-core not found - installing..."
    irm https://sabermage.github.io/spt-releases/install.ps1 | iex
    # The user-PATH registration only reaches NEW terminals -- use the
    # absolute install path for everything in THIS process:
    $spt = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'
} else {
    $spt = 'spt'
}
& $spt --version
```

## The Windows PATH-refresh gotcha

The installer registers the binary directory on the **user** PATH via the
registry. Already-running processes — including the terminal (and *your
bootstrap*) that just ran the installer — do not see registry PATH changes.

So: **the first invocation after an install must use the absolute path**
(`%LOCALAPPDATA%\spt\bin\spt.exe`; the installer prints it). Every new
terminal after that finds `spt` normally. The snippets above bake this in.
On Linux the equivalent (a `~/.profile` entry the current shell hasn't
sourced) is handled the same way: `$HOME/.local/bin/spt` absolutely, once.


sed -i 's|%LOCALAPPDATA%\\spt\\bin|%LOCALAPPDATA%\\spt-core\\bin|g' docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md && sed -i "s|Join-Path \$env:LOCALAPPDATA 'spt\\\\bin\\\\spt.exe'|Join-Path \$env:LOCALAPPDATA 'spt-core\\\\bin\\\\spt.exe'|" docs-site/src/harness-contract/install-on-demand.md && grep -rn "spt\\\\bin" docs-site/src installer/ | grep -v "spt-core"

Sweep docs to new install dir


(Bash completed with no output)


pwsh -NoProfile -Command "[scriptblock]::Create((Get-Content -Raw installer/install.ps1)) | Out-Null; 'PARSE OK'" && cargo test -p spt --test oneliner_e2e 2>&1 | tail -4

Parse-check installer + run e2e


PARSE OK
test install_script_against_staged_release ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s


PowerShell

$stage = Join-Path $env:TEMP "spt-stage-$(Get-Random)"; New-Item -ItemType Directory $stage | Out-Null; Copy-Item target\release\spt.exe (Join-Path $stage 'spt-x86_64-windows.exe'); $h = (Get-FileHash (Join-Path $stage 'spt-x86_64-windows.exe') -Algorithm SHA256).Hash.ToLower(); "$h *spt-x86_64-windows.exe" | Set-Content (Join-Path $stage 'SHA256SUMS') -Encoding ascii; $env:SPT_INSTALL_ASSET_BASE = $stage; & powershell -NoProfile -File installer\install.ps1; Remove-Item -Recurse -Force $stage

Run new installer end-to-end with dev binary as staged asset


spt-install: fetching spt-x86_64-windows.exe ...
spt-install: sha256 verified.
spt-install: migrated: removed v0.1.0 binary at C:\Users\decid\AppData\Local\spt\bin\spt.exe
spt-install: migrated: removed C:\Users\decid\AppData\Local\spt\bin from your user PATH
spt-install: installed C:\Users\decid\AppData\Local\spt-core\bin\spt.exe
spt 0.1.0
spt-install: Registered C:\Users\decid\AppData\Local\spt-core\bin on your user PATH (takes effect in NEW terminals only).
spt-install: in THIS terminal, invoke it as: C:\Users\decid\AppData\Local\spt-core\bin\spt.exe


Test-Path "$env:LOCALAPPDATA\spt\bin"; Test-Path "$env:LOCALAPPDATA\spt\owlery"; & "$env:LOCALAPPDATA\spt-core\bin\spt.exe" subnet --help | Select-Object -First 1; ([Environment]::GetEnvironmentVariable('Path','User') -split ';' | Where-Object { $_ -match 'spt' })

Verify migration state + legacy home intact


False
True
Subnet membership: status, create, show-totp.
C:\Users\decid\AppData\Local\spt-core\bin


Clean: old `spt\bin` gone, legacy plugin's `owlery` untouched, new install answers `subnet`, user PATH carries only `spt-core\bin`. Gates + commit:


Bash

cargo run -p xtask -- check 2>&1 | tail -1 && traceable-reqs check >/dev/null 2>&1; echo "reqs=$?"

Drift + traceability gates


xtask check: OK
reqs=0


git add -A && git commit -m "$(cat <<'EOF'
fix(installer): Windows install dir spt\bin -> spt-core\bin + v0.1.0 migration

%LOCALAPPDATA%\spt is the LEGACY SPT plugin's live state home (owlery/,
psyches/, pulses/ ...) — v0.1.0's installer cohabiting its bin/ inside it
was a collision waiting for adoption. The state home was deliberately
suffixed (%LOCALAPPDATA%\spt-core); the install dir now matches.

- install.ps1: default dir -> %LOCALAPPDATA%\spt-core\bin. New migration
  block (default-dir path only; an explicit SPT_INSTALL_DIR opts out of
  any migration opinion): removes exactly the old spt.exe + the bin dir
  if emptied — never the parent, it holds the legacy plugin's state —
  and strips the old dir from the user PATH. Locked-binary removal
  degrades to a warning; idempotent like the rest of the script.
- install.sh unchanged: ~/.local/bin is a standard shared user bin, no
  cohabitation to fix.
- docs swept (reference/install.md, harness-contract/install-on-demand.md).

Verified live on hfenduleam against the real v0.1.0 install (staged-asset
mode): old binary + PATH entry removed, legacy plugin home untouched,
new dir registered, installed binary answers `spt subnet`. oneliner_e2e,
xtask check, traceable-reqs check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)" && git log --oneline -1

Commit installer migration


warning: in the working copy of 'docs-site/src/harness-contract/install-on-demand.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/reference/install.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform acd9a71] fix(installer): Windows install dir spt\bin -> spt-core\bin + v0.1.0 migration
 1 file changed, 39 insertions(+), 2 deletions(-)
acd9a71 fix(installer): Windows install dir spt\bin -> spt-core\bin + v0.1.0 migration


git show --stat HEAD | tail -5; git status --short; grep -n "spt-core\\\\bin\|spt\\\\bin" docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md

Verify docs actually committed


    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

 installer/install.ps1 | 41 +++++++++++++++++++++++++++++++++++++++--
 1 file changed, 39 insertions(+), 2 deletions(-)


grep -n "LOCALAPPDATA" docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md

Inspect current doc content


docs-site/src/reference/install.md:23:   `%LOCALAPPDATA%\spt\bin`).
docs-site/src/harness-contract/install-on-demand.md:52:    $spt = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'
docs-site/src/harness-contract/install-on-demand.md:66:(`%LOCALAPPDATA%\spt\bin\spt.exe`; the installer prints it). Every new


<tool_use_error>File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.</tool_use_error>

Error: File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.


Edit

$spt = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'

$spt = Join-Path $env:LOCALAPPDATA 'spt-core\bin\spt.exe'


(`%LOCALAPPDATA%\spt\bin\spt.exe`; the installer prints it). Every new

(`%LOCALAPPDATA%\spt-core\bin\spt.exe`; the installer prints it). Every new


Bash

sed -n 20,26p docs-site/src/reference/install.md; sed -n 50,54p docs-site/src/harness-contract/install-on-demand.md; sed -n 64,68p docs-site/src/harness-contract/install-on-demand.md

Re-read current doc lines


2. Download the platform binary and the release's `SHA256SUMS`.
3. **Verify the sha256** — a mismatch refuses before anything is placed.
4. Place the binary under the per-OS install root (`~/.local/bin` /
   `%LOCALAPPDATA%\spt\bin`).
5. Register the **user** PATH (at most once; idempotent re-runs).
6. Print the absolute installed path — on Windows the PATH change reaches
   **new** terminals only, so the first invocation in the current one uses
    # The user-PATH registration only reaches NEW terminals -- use the
    # absolute install path for everything in THIS process:
    $spt = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'
} else {
    $spt = 'spt'

So: **the first invocation after an install must use the absolute path**
(`%LOCALAPPDATA%\spt\bin\spt.exe`; the installer prints it). Every new
terminal after that finds `spt` normally. The snippets above bake this in.
On Linux the equivalent (a `~/.profile` entry the current shell hasn't


perl -pi -e 's/%LOCALAPPDATA%\\spt\\bin/%LOCALAPPDATA%\\spt-core\\bin/g' docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md && perl -pi -e "s/'spt\\\\bin\\\\spt\\.exe'/'spt-core\\\\bin\\\\spt.exe'/" docs-site/src/harness-contract/install-on-demand.md && grep -n "LOCALAPPDATA" docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md

Apply doc path updates via perl, verify


grep -c 'spt.bin' docs-site/src/reference/install.md; sed -n 23p docs-site/src/reference/install.md | od -c | head -3

Hex-dump the stubborn line


1
0000000               `   %   L   O   C   A   L   A   P   P   D   A   T
0000020   A   %   \   s   p   t   \   b   i   n   `   )   .  \n
0000036


perl -pi -e 's/\\spt\\bin/\\spt-core\\bin/g' docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md; grep -n "LOCALAPPDATA" docs-site/src/reference/install.md docs-site/src/harness-contract/install-on-demand.md

Single-quoted perl substitution + verify


Shell mangling quoting. PowerShell instead:


PowerShell

foreach ($f in 'docs-site/src/reference/install.md','docs-site/src/harness-contract/install-on-demand.md') { (Get-Content $f -Raw) -replace '\\spt\\bin','\spt-core\bin' | Set-Content $f -NoNewline -Encoding utf8 }; Select-String -Path 'docs-site/src/reference/install.md','docs-site/src/harness-contract/install-on-demand.md' -Pattern 'LOCALAPPDATA' | ForEach-Object { $_.Line.Trim() }

Replace install dir refs via PowerShell


`%LOCALAPPDATA%\spt-core\bin`).
$spt = Join-Path $env:LOCALAPPDATA 'spt\bin\spt.exe'
(`%LOCALAPPDATA%\spt-core\bin\spt.exe`; the installer prints it). Every new


$f='docs-site/src/harness-contract/install-on-demand.md'; (Get-Content $f -Raw) -replace "'spt\\bin\\spt\.exe'","'spt-core\bin\spt.exe'" | Set-Content $f -NoNewline -Encoding utf8; Select-String -Path $f -Pattern 'LOCALAPPDATA' | ForEach-Object { $_.Line.Trim() }

Fix remaining snippet path


$spt = Join-Path $env:LOCALAPPDATA 'spt-core\bin\spt.exe'
(`%LOCALAPPDATA%\spt-core\bin\spt.exe`; the installer prints it). Every new


Bash

git diff --stat; cargo run -p xtask -- check 2>&1 | tail -1 && git add -A && git commit -m "$(cat <<'EOF'
docs(site): sweep install-dir references to %LOCALAPPDATA%\spt-core\bin

Companion to acd9a71 (the installer relocation): reference/install.md and
harness-contract/install-on-demand.md (incl. the bootstrap snippet's
absolute path) now name the new Windows install dir. These were meant to
ride the installer commit; a quoting bug made the sed a silent no-op and
the verification grep shared the same flaw — caught on post-commit
re-inspection, fixed via explicit replacement + literal re-grep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)" && git log --oneline -2

Gate + commit doc sweep


warning: in the working copy of 'docs-site/src/harness-contract/install-on-demand.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/reference/install.md', LF will be replaced by CRLF the next time Git touches it
 docs-site/src/harness-contract/install-on-demand.md | 4 ++--
 docs-site/src/reference/install.md                  | 2 +-
 2 files changed, 3 insertions(+), 3 deletions(-)
xtask check: OK
warning: in the working copy of 'docs-site/src/harness-contract/install-on-demand.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'docs-site/src/reference/install.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform febea12] docs(site): sweep install-dir references to %LOCALAPPDATA%\spt-core\bin
 2 files changed, 3 insertions(+), 3 deletions(-)
febea12 docs(site): sweep install-dir references to %LOCALAPPDATA%\spt-core\bin
acd9a71 fix(installer): Windows install dir spt\bin -> spt-core\bin + v0.1.0 migration


Done. Two commits:

- **acd9a71** — installer: default → `%LOCALAPPDATA%\spt-core\bin`, plus migration block (removes exactly the old `spt.exe` + emptied `bin\`, never the parent — that's the legacy plugin's live home; strips old PATH entry; explicit `SPT_INSTALL_DIR` opts out). Verified **live on this machine** via the real script in staged-asset mode: migrated old install, legacy `owlery/` untouched, new dir on PATH, binary answers `subnet`.
- **febea12** — docs sweep (first attempt was a silent sed no-op with a matching-flawed verify grep; caught, fixed, re-verified literally).

Linux untouched — `~/.local/bin` is a standard shared bin, no cohabitation.

One adoption-relevant remainder: the **public** `install.ps1` on Pages still serves the old dir until the next docs-publish/release pushes the site. Lands automatically with v0.1.1; or dispatch docs-publish sooner if you want the fix public before that.

Current install state for your acceptance run: HFENDULEAM `%LOCALAPPDATA%\spt-core\bin\spt.exe` (new terminals find it as `spt`), gravity `~/.local/bin/spt` — both at the green build (+ the installer change doesn't affect binaries).


<command-message>grill-with-docs</command-message>
<command-name>/grill-with-docs</command-name>
<command-args>i ran `spt subnet create` [Image #7], but i checked the running processes and there is no spt.exe. how will another node join the subnet if the sole existing node isn't online?</command-args>


Base directory for this skill: C:\Users\decid\.ccs\instances\bigscreen\skills\grill-with-docs

<what-to-do>

Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.

Ask the questions one at a time, waiting for feedback on each question before continuing.

If a question can be answered by exploring the codebase, explore the codebase instead.

</what-to-do>

<supporting-info>

## Domain awareness

During codebase exploration, also look for existing documentation:

### File structure

Most repos have a single context:

```
/
├── CONTEXT.md
├── docs/
│   └── adr/
│       ├── 0001-event-sourced-orders.md
│       └── 0002-postgres-for-write-model.md
└── src/
```

If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:

```
/
├── CONTEXT-MAP.md
├── docs/
│   └── adr/                          ← system-wide decisions
├── src/
│   ├── ordering/
│   │   ├── CONTEXT.md
│   │   └── docs/adr/                 ← context-specific decisions
│   └── billing/
│       ├── CONTEXT.md
│       └── docs/adr/
```

Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.

## During the session

### Challenge against the glossary

When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"

### Sharpen fuzzy language

When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."

### Discuss concrete scenarios

When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.

### Cross-reference with code

When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"

### Update CONTEXT.md inline

When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).

`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.

### Offer ADRs sparingly

Only offer to create an ADR when all three are true:

1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons

If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).

</supporting-info>

ARGUMENTS: i ran `spt subnet create` [Image #7], but i checked the running processes and there is no spt.exe. how will another node join the subnet if the sole existing node isn't online?


[Image: source: C:\Users\decid\Documents\ShareX\Screenshots\2026-06\WindowsTerminal_gQgGm35hHa.png]


<owl_reachability_notice>
You have an active owl perch. When spawning subagents or doing work, use run_in_background: true so you stay reachable for incoming messages.
</owl_reachability_notice>


CAVEMAN MODE ACTIVE (full). Drop articles/filler/pleasantries/hedging. Fragments OK. Code/commits/security: write normal.


/grill-with-docs i ran `spt subnet create` [Image #7], but i checked the running processes and there is no spt.exe. how will another node join the subnet if the sole existing node isn't online?


Verifying against code before answering — `cmd_subnet_create` path:


grep -n "ensure_running" crates/spt/src/cli.rs crates/spt/src/wansend.rs | head; grep -n "auto-start\|ensure_running\|OS-service\|service" docs/DEFERRED.md 2>/dev/null | head -5; grep -n "Daemon\b" CONTEXT.md | head -5

Map daemon auto-start trigger points


crates/spt/src/cli.rs:664:    if let Err(e) = spt_daemon::ensure_running() {
crates/spt/src/cli.rs:1216:    if let Err(e) = spt_daemon::ensure_running() {
crates/spt/src/cli.rs:2016:    if spt_daemon::ensure_running().is_err() {
crates/spt/src/wansend.rs:57:    if spt_daemon::daemon::ensure_running().is_err() {
crates/spt/src/wansend.rs:168:    if spt_daemon::daemon::ensure_running().is_err() {
crates/spt/src/wansend.rs:280:    if spt_daemon::daemon::ensure_running().is_err() {
crates/spt/src/wansend.rs:364:    if spt_daemon::daemon::ensure_running().is_err() {
19:| OS-service registration at install (REQ-INSTALL-1's third leg: systemd user service / Windows service or scheduled task for the always-on guarantee) | installer v0.1 (M6-D2, grill decision 3) | Daemon auto-start on any `spt` invocation covers dev-stage use; the minimal non-interactive script stays non-OS-entangled (also serves REQ-INSTALL-2's repackaging stance). Honest gap: a node is unreachable after reboot until something invokes `spt` | First always-on deployment need (headless/server node, or the GUI milestone's background expectations) |
154:**Daemon auto-start:** the daemon is per-machine always-on (OS-service registered), but any `spt api` invocation that needs it will **start it if absent** (fresh boot, crash, never-installed-as-service). `$SPT listen` for the first SPT session on a machine thus transparently spins up the daemon. Ensure-running lives in the `api` layer generally; `listen` is the reliable anchor.
560:**Daemon lifecycle:** registered as a systemd user service (Linux) / Windows service or scheduled task for the always-on guarantee, with `spt api`-triggered auto-start as the fallback (above). The first network bind triggers the OS firewall prompt here.


Confirmed against code — your screenshot caught a real gap. Facts:

1. `cmd_subnet_create` mints the seed, prints, **exits**. No `ensure_running` anywhere in its path (join has it; create/show-totp/status don't).
2. So after `create`: no daemon ⇒ no pairing-responder ALPN, no meet listener ⇒ a joiner gets `NO_SEED_HOLDER`. The quickstart's create→join flow **fails as written** on a fresh box.
3. M7 decision 8 ("responder always-on in every **member daemon**") silently assumed a daemon exists. Nothing makes one exist. `docs/DEFERRED.md` documents the *reboot* half honestly (OS-service deferred, "unreachable after reboot until something invokes spt") — but the create-then-dark case is worse than documented: it's dark *immediately*.

(Side observation from your capture: `HOME_ADOPTED:alice` — your real home had a leftover `alice` perch from earlier testing; harmless, but your acceptance run wasn't on a fresh `SPT_HOME`.)

So — grilling, one decision at a time:

**Q1: Should membership-bearing verbs guarantee daemon liveness?**

The contradiction to resolve: CONTEXT.md says the daemon is "per-machine always-on… any invocation that needs it starts it if absent." Does `subnet create` *need* it? My claim: **yes — membership implies reachability.** Minting a subnet is precisely the declaration "this machine now answers joins"; a sole seed-holder with no responder is a contradiction in terms, not a lazy optimization.

**Recommendation:** `subnet create` calls `ensure_running()` after the persist (refused/failed create starts nothing). Same for a successful `join` (already covered — the ceremony itself needs the daemon). `status`/`show-totp` stay passive (read-only verbs shouldn't spawn infrastructure). The reboot gap remains DEFERRED's OS-service item — separate decision.

Agree, or do you want the stronger rule (any `spt` invocation on a member node ensures the daemon)?


agree. if it's offline when a dependent command is invoked, said command should stdout lines of "coming online" status text and conclude with a divider section before fulfilling the command in question.


Logged. Before implementing — two design points to pin, one at a time.

**Q2: which stream carries the "coming online" chatter?**

You said stdout. Cross-referencing the codebase convention: stdout is reserved for *payload* (the TOTP code, otpauth URI, QR, status tables — things people pipe); all progress/status lines (`READY:`, `SENT:`, `HOME_ADOPTED:`, `DAEMON_START_WARN:`) go to **stderr**. If the coming-online lines land on stdout, `spt subnet create BIGNET | something` swallows daemon chatter into the payload.

**Recommendation:** stderr for the status lines + divider — renders identically in a terminal (your screenshot mixes both streams already), keeps pipes clean. Shape:

```
daemon: offline — coming online…
daemon: online — HFENDULEAM (bcead52b…)
────────────────────────────────────────
<command output as today>
```

Divider only when a start actually happened (already-running daemon = zero chatter, no divider).

Accept stderr, or do you specifically want these on stdout (and why — agent-readability through a stdout-only capture path)?


agree. if it's offline when a dependent command is invoked, said command should stdout lines of "coming online" status text and conclude with a divider section before fulfilling the command in questio…


agree with your points. be more explicit: "spt-core daemon is offline - coming online... // spt-core daemon: online - HFENDULEAM [...] spt subnets joined // [header underline] // BIGNET: attached // OTHER: detached // [...]" --- accept stderr


Spec captured — and it smuggles in a new domain term that needs pinning before I build it: **attached / detached**.

**Q3: what exactly does "attached" mean for a subnet?**

Nothing in CONTEXT.md defines it. Your example shows the daemon *online* yet `OTHER: detached` — so it can't simply mean "daemon up." Proposed canonical semantics, grounded in real failure modes in the code:

- **attached** — this node's daemon is *actively serving* that subnet's membership right now: pairing responder reachable, meet listener rotating, registry gossip pumping.
- **detached** — the membership record (seed) is held on disk, but nothing is serving it.

Honest cases that produce `detached` with the daemon online:

1. **Net-less broker** — `daemon.rs` degrades to a no-network broker when the endpoint bind fails (`NET_BIND_FAIL`); daemon alive, *all* subnets detached.
2. **Meet bind failure** — `PAIR_MEET_BIND_FAIL` skips a subnet for that rotation window (transient, per-subnet).

Under this definition, the normal post-`create` banner reads all-attached — and `BIGNET: detached` would have been exactly the diagnostic your screenshot deserved.

**Recommendation:** adopt attached/detached as above; banner is transition-only chatter (the standing view stays `spt subnet status`). I'd compute it as: net host up ∧ subnet in the daemon's served set. On ratify I write the term into CONTEXT.md's glossary.

One probe: do you also intend a *deliberate* detach someday (hold the seed, refuse to serve — a "mute this subnet" verb)? If yes the term needs room for "deliberately detached" vs "degraded"; if no I'll define it purely as a health state.