git add traceable-reqs.toml docs/adr/0014-publish-target-repo-and-lapse-proof-canonical-url.md; git commit -m @'
docs(rel): D1 — spt-releases public face shipped; REQ-REL-1 doc stage live

SaberMage/spt-releases created (private; flips public at D7 per ADR-0014).
Pushed README per grill decision 7 (verbatim tagline, honest ecosystem
paragraph with Pi disambiguated, works-today/next status table, the two
canonical-Pages-URL install one-liners, per-audience getting-started, docs
+ llms.txt pointers) and the LICENSE-MIT / LICENSE-BINARY split with the
royalty-free adapter clause (spt-releases@2debda8). Pages itself stays
unconfigurable while private (free plan) — live at the D7 flip, as decided.

REQ-REL-1 activated at doc (tag on ADR-0014); impl arm lands with the D5
publish pipeline. 139/139 green.

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

Commit D1 traceability activation in spt-core


warning: in the working copy of 'docs/adr/0014-publish-target-repo-and-lapse-proof-canonical-url.md', LF will be replaced by CRLF the next time Git touches it
[dev-freeform e430327] docs(rel): D1 — spt-releases public face shipped; REQ-REL-1 doc stage live
 2 files changed, 4 insertions(+), 1 deletion(-)


Updated task #1 status


Updated task #2 status


PowerShell

Get-ChildItem C:\Users\decid\Documents\projects\spt-core -Force | Select-Object Name; Get-ChildItem C:\Users\decid\Documents\projects\spt-core\.github\workflows | Select-Object Name

List repo top level + workflows


Name
----
.claude
.git
.github
adapters
crates
docs
src
target
tests
.gitignore
AGENTS.md
Cargo.lock
Cargo.toml
CI-SELFHOST-PLAN.md
CLAUDE.md
CONTEXT.md
M0-PLAN.md
M1-PLAN.md
M2a-PLAN.md
M2b-PLAN.md
M3-PLAN.md
M3a-PLAN.md
M3b-PLAN.md
M3c-PLAN.md
M4-D2-WIRE-PLAN.md
M4-D2f-PLAN.md
M4-D2g-PLAN.md
M4-D3-PLAN.md
M4-D4-PLAN.md
M4-D5-PLAN.md
M4-D6-PLAN.md
M4-D7-PLAN.md
M4-D7.5-PLAN.md
M4-D8-PLAN.md
M4-D9-PLAN.md
M4-PLAN.md
M5-D3-PLAN.md
M5-D4-PLAN.md
M5-D5-PLAN.md
M5-D6-PLAN.md
M5-D7-PLAN.md
M5-D8-PLAN.md
M5-D9-PLAN.md
M5-PLAN.md
M6-PLAN.md
PRD.md
ROADMAP.md
traceable-reqs.toml
ci.yml


1	name: ci
2
3	on:
4	  push:
5	    branches: [main, dev-freeform]
6	  pull_request:
7
8	# Cancel superseded runs on the same ref.
9	concurrency:
10	  group: ci-${{ github.ref }}
11	  cancel-in-progress: true
12
13	jobs:
14	  # ---- Rust build + test + lint (cross-platform: exercises both the
15	  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
16	  #
17	  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
18	  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
19	  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
20	  # paid once per machine); hung tests debuggable on-box. Both boxes own
21	  # their rust toolchains (rustup-managed); no toolchain action, no
22	  # rust-cache (the persistent target dir IS the cache).
23	  test:
24	    strategy:
25	      fail-fast: false
26	      matrix:
27	        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
28	    runs-on: ${{ matrix.runner }}
29	    # A wedged test must not block the single self-hosted runner slot (the
30	    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
31	    timeout-minutes: 25
32	    steps:
33	      - uses: actions/checkout@v5
34	      - name: Build
35	        run: cargo build --workspace --all-targets
36	      - name: Test
37	        run: cargo test --workspace
38	      - name: Clippy (deny warnings)
39	        run: cargo clippy --workspace --all-targets -- -D warnings
40
41	      # ---- M5-D8a real-shell dogfood E2E (thin cross-repo hook) ----
42	      # The notify adapter lives in its own repo (SaberMage/spt-shell-notify,
43	      # public): the manifest + binary are the ONLY glue — zero spt-core
44	      # source integration. CI clones + builds it, then re-runs the
45	      # env-gated E2E leg against the real binary (the normal `cargo test`
46	      # sweep silent-skips the same test when SPT_NOTIFY_SHELL_BIN is unset).
47	      - name: Checkout spt-shell-notify (real-shell dogfood)
48	        uses: actions/checkout@v5
49	        with:
50	          repository: SaberMage/spt-shell-notify
51	          path: .adapter-notify
52	      - name: Build notify-shell
53	        run: cargo build --manifest-path .adapter-notify/Cargo.toml
54	      - name: Real-shell E2E (notify) — linux
55	        if: runner.os == 'Linux'
56	        run: cargo test -p spt --test notify_shell_e2e -- --nocapture
57	        env:
58	          SPT_NOTIFY_SHELL_BIN: ${{ github.workspace }}/.adapter-notify/target/debug/notify-shell
59	      - name: Real-shell E2E (notify) — windows
60	        if: runner.os == 'Windows'
61	        run: cargo test -p spt --test notify_shell_e2e -- --nocapture
62	        env:
63	          SPT_NOTIFY_SHELL_BIN: ${{ github.workspace }}\.adapter-notify\target\debug\notify-shell.exe
64
65	      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
66	      # Opt-in per push: runs ONLY when the head commit message carries
67	      # "[budget]" — both rig hosts measure in one tagged push, output lands
68	      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
69	      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
70	      # run silent-skips it inside the normal test sweep.
71	      - name: Dormancy budget (shell seats)
72	        if: contains(github.event.head_commit.message, '[budget]')
73	        run: cargo test -p spt-daemon --test budget --release -- --nocapture
74	        env:
75	          SPT_BUDGET: "1"
76	      # Two shells: the Windows runner has no bash on PATH (the first tagged
77	      # run failed on `shell: bash` there), so each OS keeps its native one.
78	      - name: Dormancy budget (real adapter seats, when installed) — linux
79	        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
80	        shell: bash
81	        run: |
82	          if command -v claude >/dev/null; then
83	            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
84	              cargo test -p spt-daemon --test budget --release -- --nocapture
85	          else
86	            echo "claude not installed on this runner — shell numbers only"
87	          fi
88	      - name: Dormancy budget (real adapter seats, when installed) — windows
89	        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
90	        shell: powershell
91	        run: |
92	          if (Get-Command claude -ErrorAction SilentlyContinue) {
93	            $env:SPT_BUDGET = "1"
94	            $env:SPT_BUDGET_N = "3"
95	            $env:SPT_BUDGET_PROGRAM = "claude"
96	            $env:SPT_BUDGET_SETTLE_MS = "15000"
97	            cargo test -p spt-daemon --test budget --release -- --nocapture
98	          } else {
99	            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
100	            # without a BOM as ANSI, so a multibyte dash corrupts the string
101	            # terminator (the first tagged-run failure mode here).
102	            Write-Output "claude not installed on this runner - shell numbers only"
103	          }
104
105	  # ---- D9-6 two-host rig ladder (M4-PLAN §D9b) ----
106	  # Opt-in per push: runs ONLY when the head commit message carries
107	  # "[twohost]". The two rig hosts ARE the self-hosted runners, so one tagged
108	  # push runs both roles; `needs: test` releases the two jobs together (each
109	  # runner has one slot — without it one role could sit queued behind the
110	  # other machine's test leg and starve the pairing window). The harness is
111	  # also env-gated (SPT_TWO_HOST=1), so untagged runs silent-skip it inside
112	  # the normal sweep. Not a standing CI job: the recurring cross-runner
113	  # choreography stays an M5 seam (docs/TWO-HOST-RUNBOOK.md is the proof
114	  # procedure).
115	  twohost-b:
116	    if: contains(github.event.head_commit.message, '[twohost]')
117	    needs: test
118	    runs-on: [self-hosted, Linux, gravity]
119	    timeout-minutes: 25
120	    steps:
121	      - uses: actions/checkout@v5
122	      # The M5 notif→toast rung renders through the REAL notify shell
123	      # (M5-D9a; same cross-repo hook as the test job's E2E).
124	      - name: Checkout spt-shell-notify (toast rung)
125	        uses: actions/checkout@v5
126	        with:
127	          repository: SaberMage/spt-shell-notify
128	          path: .adapter-notify
129	      - name: Build notify-shell
130	        run: cargo build --manifest-path .adapter-notify/Cargo.toml
131	      # The real-mode toast rung also needs the workspace `spt` binary beside
132	      # the test executable (the resident shell drives `spt api`); the first
133	      # D9a run proved the twohost job's cargo context does not share the
134	      # test job's artifacts — build it in-job (warm: seconds).
135	      - name: Build spt binary (toast rung)
136	        run: cargo build -p spt --bin spt
137	      - name: Two-host ladder — role B (seed-holder/server)
138	        run: cargo test -p spt-daemon --test twohost -- --nocapture
139	        env:
140	          SPT_TWO_HOST: "1"
141	          SPT_TWO_HOST_ROLE: "b"
142	          SPT_TWO_HOST_SECRET: "rig-twohost-ci"
143	          SPT_TWO_HOST_PEER_IP: "100.68.35.65" # hfenduleam (tailscale)
144	          SPT_TWO_HOST_WAIT_SECS: "900"
145	          SPT_TWO_HOST_NOTIFY_BIN: ${{ github.workspace }}/.adapter-notify/target/debug/notify-shell
146	  twohost-a:
147	    if: contains(github.event.head_commit.message, '[twohost]')
148	    needs: test
149	    runs-on: [self-hosted, Windows, hfenduleam]
150	    timeout-minutes: 25
151	    steps:
152	      - uses: actions/checkout@v5
153	      - name: Checkout spt-shell-notify (toast rung)
154	        uses: actions/checkout@v5
155	        with:
156	          repository: SaberMage/spt-shell-notify
157	          path: .adapter-notify
158	      - name: Build notify-shell
159	        run: cargo build --manifest-path .adapter-notify/Cargo.toml
160	      - name: Build spt binary (toast rung)
161	        run: cargo build -p spt --bin spt
162	      - name: Two-host ladder — role A (joiner/driver)
163	        run: cargo test -p spt-daemon --test twohost -- --nocapture
164	        env:
165	          SPT_TWO_HOST: "1"
166	          SPT_TWO_HOST_ROLE: "a"
167	          SPT_TWO_HOST_SECRET: "rig-twohost-ci"
168	          SPT_TWO_HOST_PEER_IP: "100.100.62.43" # gravity-linux (tailscale)
169	          SPT_TWO_HOST_WAIT_SECS: "900"
170	          SPT_TWO_HOST_NOTIFY_BIN: ${{ github.workspace }}\.adapter-notify\target\debug\notify-shell.exe
171
172	  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
173	  traceability:
174	    runs-on: [self-hosted, Linux, gravity]
175	    timeout-minutes: 10
176	    steps:
177	      - uses: actions/checkout@v5
178	      - name: Install traceable-reqs
179	        # The self-hosted gravity runner carries a source-built binary in
180	        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
181	        # Ubuntu 22.04 has 2.35). The download is the portability fallback
182	        # for any other runner.
183	        run: |
184	          if command -v traceable-reqs >/dev/null; then
185	            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
186	          else
187	            gh release download --repo BigscreenVR/traceable-reqs \
188	              --pattern '*linux-x86_64' --output traceable-reqs
189	            chmod +x traceable-reqs
190	          fi
191	        env:
192	          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
193	          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
194	          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
195	          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
196	      - name: Coverage gate (hard — fails PR on uncovered activated req)
197	        run: ./traceable-reqs check --json
198	      - name: Quality audit (advisory)
199	        run: ./traceable-reqs lint || true
200

name: ci

on:
  push:
    branches: [main, dev-freeform]
  pull_request:

# Cancel superseded runs on the same ref.
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ---- Rust build + test + lint (cross-platform: exercises both the
  # cfg(windows) OpenProcess path and the cfg(unix) libc path in proc.rs) ----
  #
  # CI-SELFHOST-PLAN.md: fully self-hosted (2026-06-03) — hfenduleam carries
  # the Windows leg, gravity-linux the Linux leg + traceability. Free minutes;
  # persistent cargo/target = warm builds (the multi-exe iroh link cost is
  # paid once per machine); hung tests debuggable on-box. Both boxes own
  # their rust toolchains (rustup-managed); no toolchain action, no
  # rust-cache (the persistent target dir IS the cache).
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [[self-hosted, Linux, gravity], [self-hosted, Windows, hfenduleam]]
    runs-on: ${{ matrix.runner }}
    # A wedged test must not block the single self-hosted runner slot (the
    # 2026-06-03 handoff.rs ConPTY stall burned 22 hosted minutes unbounded).
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Build
        run: cargo build --workspace --all-targets
      - name: Test
        run: cargo test --workspace
      - name: Clippy (deny warnings)
        run: cargo clippy --workspace --all-targets -- -D warnings

      # ---- M5-D8a real-shell dogfood E2E (thin cross-repo hook) ----
      # The notify adapter lives in its own repo (SaberMage/spt-shell-notify,
      # public): the manifest + binary are the ONLY glue — zero spt-core
      # source integration. CI clones + builds it, then re-runs the
      # env-gated E2E leg against the real binary (the normal `cargo test`
      # sweep silent-skips the same test when SPT_NOTIFY_SHELL_BIN is unset).
      - name: Checkout spt-shell-notify (real-shell dogfood)
        uses: actions/checkout@v5
        with:
          repository: SaberMage/spt-shell-notify
          path: .adapter-notify
      - name: Build notify-shell
        run: cargo build --manifest-path .adapter-notify/Cargo.toml
      - name: Real-shell E2E (notify) — linux
        if: runner.os == 'Linux'
        run: cargo test -p spt --test notify_shell_e2e -- --nocapture
        env:
          SPT_NOTIFY_SHELL_BIN: ${{ github.workspace }}/.adapter-notify/target/debug/notify-shell
      - name: Real-shell E2E (notify) — windows
        if: runner.os == 'Windows'
        run: cargo test -p spt --test notify_shell_e2e -- --nocapture
        env:
          SPT_NOTIFY_SHELL_BIN: ${{ github.workspace }}\.adapter-notify\target\debug\notify-shell.exe

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----
      # Opt-in per push: runs ONLY when the head commit message carries
      # "[budget]" — both rig hosts measure in one tagged push, output lands
      # in the run log and is captured into docs/DORMANCY-BUDGET.md. The
      # harness itself is also env-gated (SPT_BUDGET=1), so an untagged CI
      # run silent-skips it inside the normal test sweep.
      - name: Dormancy budget (shell seats)
        if: contains(github.event.head_commit.message, '[budget]')
        run: cargo test -p spt-daemon --test budget --release -- --nocapture
        env:
          SPT_BUDGET: "1"
      # Two shells: the Windows runner has no bash on PATH (the first tagged
      # run failed on `shell: bash` there), so each OS keeps its native one.
      - name: Dormancy budget (real adapter seats, when installed) — linux
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Linux'
        shell: bash
        run: |
          if command -v claude >/dev/null; then
            SPT_BUDGET=1 SPT_BUDGET_N=3 SPT_BUDGET_PROGRAM=claude SPT_BUDGET_SETTLE_MS=15000 \
              cargo test -p spt-daemon --test budget --release -- --nocapture
          else
            echo "claude not installed on this runner — shell numbers only"
          fi
      - name: Dormancy budget (real adapter seats, when installed) — windows
        if: contains(github.event.head_commit.message, '[budget]') && runner.os == 'Windows'
        shell: powershell
        run: |
          if (Get-Command claude -ErrorAction SilentlyContinue) {
            $env:SPT_BUDGET = "1"
            $env:SPT_BUDGET_N = "3"
            $env:SPT_BUDGET_PROGRAM = "claude"
            $env:SPT_BUDGET_SETTLE_MS = "15000"
            cargo test -p spt-daemon --test budget --release -- --nocapture
          } else {
            # ASCII only: Windows PowerShell 5.1 reads the step's temp script
            # without a BOM as ANSI, so a multibyte dash corrupts the string
            # terminator (the first tagged-run failure mode here).
            Write-Output "claude not installed on this runner - shell numbers only"
          }

  # ---- D9-6 two-host rig ladder (M4-PLAN §D9b) ----
  # Opt-in per push: runs ONLY when the head commit message carries
  # "[twohost]". The two rig hosts ARE the self-hosted runners, so one tagged
  # push runs both roles; `needs: test` releases the two jobs together (each
  # runner has one slot — without it one role could sit queued behind the
  # other machine's test leg and starve the pairing window). The harness is
  # also env-gated (SPT_TWO_HOST=1), so untagged runs silent-skip it inside
  # the normal sweep. Not a standing CI job: the recurring cross-runner
  # choreography stays an M5 seam (docs/TWO-HOST-RUNBOOK.md is the proof
  # procedure).
  twohost-b:
    if: contains(github.event.head_commit.message, '[twohost]')
    needs: test
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      # The M5 notif→toast rung renders through the REAL notify shell
      # (M5-D9a; same cross-repo hook as the test job's E2E).
      - name: Checkout spt-shell-notify (toast rung)
        uses: actions/checkout@v5
        with:
          repository: SaberMage/spt-shell-notify
          path: .adapter-notify
      - name: Build notify-shell
        run: cargo build --manifest-path .adapter-notify/Cargo.toml
      # The real-mode toast rung also needs the workspace `spt` binary beside
      # the test executable (the resident shell drives `spt api`); the first
      # D9a run proved the twohost job's cargo context does not share the
      # test job's artifacts — build it in-job (warm: seconds).
      - name: Build spt binary (toast rung)
        run: cargo build -p spt --bin spt
      - name: Two-host ladder — role B (seed-holder/server)
        run: cargo test -p spt-daemon --test twohost -- --nocapture
        env:
          SPT_TWO_HOST: "1"
          SPT_TWO_HOST_ROLE: "b"
          SPT_TWO_HOST_SECRET: "rig-twohost-ci"
          SPT_TWO_HOST_PEER_IP: "100.68.35.65" # hfenduleam (tailscale)
          SPT_TWO_HOST_WAIT_SECS: "900"
          SPT_TWO_HOST_NOTIFY_BIN: ${{ github.workspace }}/.adapter-notify/target/debug/notify-shell
  twohost-a:
    if: contains(github.event.head_commit.message, '[twohost]')
    needs: test
    runs-on: [self-hosted, Windows, hfenduleam]
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v5
      - name: Checkout spt-shell-notify (toast rung)
        uses: actions/checkout@v5
        with:
          repository: SaberMage/spt-shell-notify
          path: .adapter-notify
      - name: Build notify-shell
        run: cargo build --manifest-path .adapter-notify/Cargo.toml
      - name: Build spt binary (toast rung)
        run: cargo build -p spt --bin spt
      - name: Two-host ladder — role A (joiner/driver)
        run: cargo test -p spt-daemon --test twohost -- --nocapture
        env:
          SPT_TWO_HOST: "1"
          SPT_TWO_HOST_ROLE: "a"
          SPT_TWO_HOST_SECRET: "rig-twohost-ci"
          SPT_TWO_HOST_PEER_IP: "100.100.62.43" # gravity-linux (tailscale)
          SPT_TWO_HOST_WAIT_SECS: "900"
          SPT_TWO_HOST_NOTIFY_BIN: ${{ github.workspace }}\.adapter-notify\target\debug\notify-shell.exe

  # ---- Requirement-traceability hard gate (TRACEABILITY.md layer 1) ----
  traceability:
    runs-on: [self-hosted, Linux, gravity]
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v5
      - name: Install traceable-reqs
        # The self-hosted gravity runner carries a source-built binary in
        # /usr/local/bin (the release asset needs GLIBC 2.39; gravity's
        # Ubuntu 22.04 has 2.35). The download is the portability fallback
        # for any other runner.
        run: |
          if command -v traceable-reqs >/dev/null; then
            ln -sf "$(command -v traceable-reqs)" ./traceable-reqs
          else
            gh release download --repo BigscreenVR/traceable-reqs \
              --pattern '*linux-x86_64' --output traceable-reqs
            chmod +x traceable-reqs
          fi
        env:
          # Cross-org private repo: the default repo-scoped GITHUB_TOKEN cannot
          # read BigscreenVR/traceable-reqs. Supply a PAT with read access to it
          # as the TRACEABLE_REQS_TOKEN repo secret. See README/CI notes.
          GH_TOKEN: ${{ secrets.TRACEABLE_REQS_TOKEN }}
      - name: Coverage gate (hard — fails PR on uncovered activated req)
        run: ./traceable-reqs check --json
      - name: Quality audit (advisory)
        run: ./traceable-reqs lint || true


D2 design. Checking DEFERRED.md + sha2 availability + tests layout.


Get-ChildItem C:\Users\decid\Documents\projects\spt-core\docs | Select-Object Name; Get-ChildItem C:\Users\decid\Documents\projects\spt-core\tests | Select-Object Name

List docs/ and tests/


Name
----
adr
reviews
spikes
CONTEXT-MEMORY.md
DEFERRED.md
DOCS-STRATEGY.md
DORMANCY-BUDGET.md
FAULT-MATRIX.md
FLAKE-LEDGER.md
KNOWN-HAZARDS.md
MANIFEST.md
STORAGE.md
TRACEABILITY.md
TWO-HOST-RUNBOOK.md
.gitkeep


crates\spt-daemon\Cargo.toml:42:sha2 = "0.10"
crates\spt-net\Cargo.toml:13:net = ["dep:iroh", "dep:tokio", "dep:hmac", "dep:sha1", "dep:sha2", "dep:data-encoding", "dep:spake2", "dep:subtle", "dep:zeroize", "dep:serde_json"]
crates\spt-net\Cargo.toml:46:sha2 = { version = "0.10", optional = true }
crates\spt-store\Cargo.toml:12:sha2 = "0.10"


1	# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
2	#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
3	# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
4	# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
5	# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
6	# M2b adds spt-live (live-agent lifecycle executor) — fifth layer, daemon-bound (not public SDK).
7	# M3a adds spt-term (session-surface mechanism: ConPTY/forkpty PTY layer) — sibling of spt-runtime, above spt-msg.
8	# M3b adds spt-daemon (broker/brain supervisor) — seventh layer, above spt-live, below spt; internal (not public SDK).
9	# M4 adds spt-net (P2P WAN/LAN transport) — sibling of spt-term/spt-runtime, above spt-msg; internal, default-on `net` feature.
10	[workspace]
11	resolver = "2"
12	members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock"]
13

# spt-core cargo workspace (M0). Acyclically-layered crates per R-ARCH-1:
#   spt-proto -> spt-store -> spt-msg -> {spt-net, spt-term, spt-runtime} -> spt-live -> spt-daemon -> spt
# M0 builds the bottom two: spt-proto (wire) + spt-store (persistence).
# M1 adds spt-msg (local messaging) — third layer, one-way above spt-store.
# M2a adds spt-runtime (harness contract: manifest schema + AgentRuntime) — fourth layer.
# M2b adds spt-live (live-agent lifecycle executor) — fifth layer, daemon-bound (not public SDK).
# M3a adds spt-term (session-surface mechanism: ConPTY/forkpty PTY layer) — sibling of spt-runtime, above spt-msg.
# M3b adds spt-daemon (broker/brain supervisor) — seventh layer, above spt-live, below spt; internal (not public SDK).
# M4 adds spt-net (P2P WAN/LAN transport) — sibling of spt-term/spt-runtime, above spt-msg; internal, default-on `net` feature.
[workspace]
resolver = "2"
members = ["crates/spt-proto", "crates/spt-store", "crates/spt-msg", "crates/spt-net", "crates/spt-term", "crates/spt-runtime", "crates/spt-live", "crates/spt-daemon", "crates/spt", "adapters/mock"]


1	[package]
2	name = "spt"
3	version = "0.0.0"
4	edition = "2021"
5	publish = false
6	description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."
7
8	[[bin]]
9	name = "spt"
10	path = "src/main.rs"
11
12	[dependencies]
13	spt-proto = { path = "../spt-proto" }
14	spt-store = { path = "../spt-store" }
15	spt-msg = { path = "../spt-msg" }
16	spt-runtime = { path = "../spt-runtime" }
17	spt-live = { path = "../spt-live" }
18	spt-daemon = { path = "../spt-daemon" }
19	# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
20	# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
21	# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
22	spt-net = { path = "../spt-net" }
23	clap = { version = "4", features = ["derive"] }
24	# The WAN send leg (D9-1-4) serializes the dial address + reads registry
25	# snapshots - same pinned shape as spt-daemon's.
26	serde_json = { version = "1", features = ["preserve_order"] }
27
28	# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
29	# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
30	# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
31	[target.'cfg(unix)'.dependencies]
32	libc = "0.2"
33
34	[target.'cfg(windows)'.dependencies]
35	windows-sys = { version = "0.59", features = [
36	    "Win32_Foundation",
37	    "Win32_Security",
38	    "Win32_System_Threading",
39	] }
40
41	[dev-dependencies]
42	tempfile = "3"
43	toml = "0.8"
44

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"


1	# Deferred Features
2
3	Intended features explicitly cut from v1 scope but committed for later. Not backlog noise — these are decided directions parked for sequencing reasons. Keep this list short and real.
4
5	| Feature | Cut from | Why deferred | Trigger to revisit |
6	|---|---|---|---|
7	| Scrollback on-disk spillover | terminal wrapper v1 | In-memory ring covers the common case; spillover adds a persistence/rotation story | First long-running session that overflows the ring usefully, or any "scroll back further than the buffer" user need |
8	| Sidecar adapter process (long-running, wire-protocol) | harness contract v1 | Manifest + `spt.exe` subcommand surface covers v1 harnesses; sidecar only earns its keep for streaming / in-memory cross-event state | A harness outgrows manifest+hooks (needs streaming or stateful coordination) |
9	| Manifest `include` / composition (option C) | manifest v1 | Single flat manifest per harness is enough until a harness wants to override only part of a section | First sub-project that needs to inherit spt-core defaults but override one section |
10	| Concrete Shell types (GameRobot, in-session-inject) — **OS-notification shell carved out: ships M5** as the dogfood proof (user decision 2026-06-04, M5-PLAN §Scope decisions) | Shell concept v1 | Model is locked; concrete surfaces are large, platform-specific, and not needed for core | First real use case for a driven surface (e.g. the 2D-world experiment) |
11	| PresenceChannel implementation (broker, dispatch/bind/thread) | endpoint types v1 | Depends on presence gossip + Shells + multi-instance routing; only the seams ship v1. **M5 ships presence *resolution* (gossip + most-recently-active API) — its future substrate; the endpoint stays deferred** (user decision 2026-06-04). <!-- [doc->REQ-PRES-1] --> **M5-D6 seam note:** the substrate now exists — the gossiped datum (`Instance.last_active_ms`, riding the registry's epoch lease + replication) and the one MRA API (`spt-daemon::presence`). A future PresenceChannel's dispatch/bind/thread styles consume exactly this datum plus the broker PresenceLog seam (REQ-EP-4); nothing about the endpoint type needs a new gossip channel. | Once Shells exist and the subnet registry is stable |
12	| OS-level input-activity presence signal (7a-A) | presence v1 | Per-platform, privacy-loaded; agent-interaction heartbeat covers the v1 goal. **Note:** distinct from the v1 broker *sensing* user input on a PTY it already holds (spt-hosted) — that is in v1 and is NOT OS-wide input monitoring | Opt-in enhancement after agent-interaction presence proves out |
13	| Instantiate-anywhere + consent gate | Instances V1-mid | Remote launch over network is additive once remote-drive of running instances works. Consent *model* is now designed (CONTEXT §Consent & security gates); **the framework (grant store + escalation + pre-consent flags) ships M5 with this capability id reserved-but-refusing** (user decision 2026-06-04) — the capability lands into it later. Defers with it: the remote-fork arm, cross-node shell spawn, `shell_wake_spawn_anywhere` *behavior* (grant shape ships M5). <!-- [doc->REQ-INST-6] --> **M5-D5b note:** remote `spt suspend/wake <id@node>` shipped (the rest-op wire family, remote-drive trust class) — it *moves existing instances between rest states only*; the remote-**fork** arm (creating an instance on a node that has none) stays here, riding instantiate-anywhere's consent gate. A wake addressed to a node with no local instance refuses with `WAKE_NO_REACHABLE_INSTANCE` naming this deferral. | Revisit-trigger met (remote-drive shipped M4); held back by user decision — capability drops into the M5 consent framework when picked up |
14	| Remote command execution on another node | off-node reach-back v1 | Highest-risk capability; needs the consent/security gate. Workarounds exist (message a local agent; interact with local agent directly). **M5 ships the framework with this capability id reserved-but-refusing** (user decision 2026-06-04) | Designed alongside instantiate-anywhere's consent gate |
15	| Shell-binary (and harness-binary) sandboxing | Shell concept v1 | A capability toolset bounds what the *agent* can ask, not what the *binary* can do with its OS perms. Baseline stance: running any adapter/shell binary = a disclosed, accepted trust risk for all spt-core users | If/when untrusted third-party shell/harness binaries become common enough to warrant OS-level sandboxing |
16	| Concrete Shell adapters (e.g. GameRobot) + the shell binaries themselves | Shell concept v1 | The Shell *model*, manifest schema, perch layout, and `api` surface are now specified; concrete shells are large, platform-specific, and not needed for core | First real driven-surface use case |
17	| REQ-CONSENT int evidence (interactive escalation E2E) | M5 closeout (D9b rule-5) | The escalation prompt''s end-to-end leg needs a real harness session answering a real consent prompt — loopback-faking it would prove nothing; the grant store + gate + CLI are impl/unit-proven | The downstream rebuilt spt plugin (v1 acceptance) — its first gated spawn IS the int evidence |
18	| REQ-INSTALL-4 int evidence (`spt adapter add --github` against a real repo) | M5 closeout (D9b rule-5) | Needs a real clone target; impl/unit cover manifest-first validation + registration. The standalone `SaberMage/spt-shell-notify` repo now makes this trivially possible | First milestone that touches adapter lifecycle again — one E2E cloning the notify repo |
19

# Deferred Features

Intended features explicitly cut from v1 scope but committed for later. Not backlog noise — these are decided directions parked for sequencing reasons. Keep this list short and real.

| Feature | Cut from | Why deferred | Trigger to revisit |
|---|---|---|---|
| Scrollback on-disk spillover | terminal wrapper v1 | In-memory ring covers the common case; spillover adds a persistence/rotation story | First long-running session that overflows the ring usefully, or any "scroll back further than the buffer" user need |
| Sidecar adapter process (long-running, wire-protocol) | harness contract v1 | Manifest + `spt.exe` subcommand surface covers v1 harnesses; sidecar only earns its keep for streaming / in-memory cross-event state | A harness outgrows manifest+hooks (needs streaming or stateful coordination) |
| Manifest `include` / composition (option C) | manifest v1 | Single flat manifest per harness is enough until a harness wants to override only part of a section | First sub-project that needs to inherit spt-core defaults but override one section |
| Concrete Shell types (GameRobot, in-session-inject) — **OS-notification shell carved out: ships M5** as the dogfood proof (user decision 2026-06-04, M5-PLAN §Scope decisions) | Shell concept v1 | Model is locked; concrete surfaces are large, platform-specific, and not needed for core | First real use case for a driven surface (e.g. the 2D-world experiment) |
| PresenceChannel implementation (broker, dispatch/bind/thread) | endpoint types v1 | Depends on presence gossip + Shells + multi-instance routing; only the seams ship v1. **M5 ships presence *resolution* (gossip + most-recently-active API) — its future substrate; the endpoint stays deferred** (user decision 2026-06-04). <!-- [doc->REQ-PRES-1] --> **M5-D6 seam note:** the substrate now exists — the gossiped datum (`Instance.last_active_ms`, riding the registry's epoch lease + replication) and the one MRA API (`spt-daemon::presence`). A future PresenceChannel's dispatch/bind/thread styles consume exactly this datum plus the broker PresenceLog seam (REQ-EP-4); nothing about the endpoint type needs a new gossip channel. | Once Shells exist and the subnet registry is stable |
| OS-level input-activity presence signal (7a-A) | presence v1 | Per-platform, privacy-loaded; agent-interaction heartbeat covers the v1 goal. **Note:** distinct from the v1 broker *sensing* user input on a PTY it already holds (spt-hosted) — that is in v1 and is NOT OS-wide input monitoring | Opt-in enhancement after agent-interaction presence proves out |
| Instantiate-anywhere + consent gate | Instances V1-mid | Remote launch over network is additive once remote-drive of running instances works. Consent *model* is now designed (CONTEXT §Consent & security gates); **the framework (grant store + escalation + pre-consent flags) ships M5 with this capability id reserved-but-refusing** (user decision 2026-06-04) — the capability lands into it later. Defers with it: the remote-fork arm, cross-node shell spawn, `shell_wake_spawn_anywhere` *behavior* (grant shape ships M5). <!-- [doc->REQ-INST-6] --> **M5-D5b note:** remote `spt suspend/wake <id@node>` shipped (the rest-op wire family, remote-drive trust class) — it *moves existing instances between rest states only*; the remote-**fork** arm (creating an instance on a node that has none) stays here, riding instantiate-anywhere's consent gate. A wake addressed to a node with no local instance refuses with `WAKE_NO_REACHABLE_INSTANCE` naming this deferral. | Revisit-trigger met (remote-drive shipped M4); held back by user decision — capability drops into the M5 consent framework when picked up |
| Remote command execution on another node | off-node reach-back v1 | Highest-risk capability; needs the consent/security gate. Workarounds exist (message a local agent; interact with local agent directly). **M5 ships the framework with this capability id reserved-but-refusing** (user decision 2026-06-04) | Designed alongside instantiate-anywhere's consent gate |
| Shell-binary (and harness-binary) sandboxing | Shell concept v1 | A capability toolset bounds what the *agent* can ask, not what the *binary* can do with its OS perms. Baseline stance: running any adapter/shell binary = a disclosed, accepted trust risk for all spt-core users | If/when untrusted third-party shell/harness binaries become common enough to warrant OS-level sandboxing |
| Concrete Shell adapters (e.g. GameRobot) + the shell binaries themselves | Shell concept v1 | The Shell *model*, manifest schema, perch layout, and `api` surface are now specified; concrete shells are large, platform-specific, and not needed for core | First real driven-surface use case |
| REQ-CONSENT int evidence (interactive escalation E2E) | M5 closeout (D9b rule-5) | The escalation prompt''s end-to-end leg needs a real harness session answering a real consent prompt — loopback-faking it would prove nothing; the grant store + gate + CLI are impl/unit-proven | The downstream rebuilt spt plugin (v1 acceptance) — its first gated spawn IS the int evidence |
| REQ-INSTALL-4 int evidence (`spt adapter add --github` against a real repo) | M5 closeout (D9b rule-5) | Needs a real clone target; impl/unit cover manifest-first validation + registration. The standalone `SaberMage/spt-shell-notify` repo now makes this trivially possible | First milestone that touches adapter lifecycle again — one E2E cloning the notify repo |


Found 14 files
docs\adr\0014-publish-target-repo-and-lapse-proof-canonical-url.md
CONTEXT.md
docs\DEFERRED.md
AGENTS.md
docs\MANIFEST.md
docs\KNOWN-HAZARDS.md
M4-D9-PLAN.md
docs\adr\0010-immutable-home-subnet-fork-not-rehome.md
docs\DORMANCY-BUDGET.md
docs\STORAGE.md
M4-D8-PLAN.md
M4-D5-PLAN.md
CLAUDE.md
M2a-PLAN.md


M6-D1: spt-releases repo + public face (README, licenses)

Create SaberMage/spt-releases private via gh; author README.md per grill decision 7; LICENSE-MIT + LICENSE-BINARY; Pages config deferred to D7 flip. Activate REQ-REL-1 doc stage.

Creating spt-releases repo + public face

M6-D2: installer scripts (install.sh / install.ps1)

Non-interactive: latest-release lookup → platform asset fetch → sha256 verify → per-OS install root → user PATH → print absolute path. Idempotent. CI rung against staged artifact + spt --version. Activate REQ-INSTALL-1/2/3/5.

M6-D3: manifest JSON Schema (schemars) + drift gate

schemars derives on spt-runtime manifest types; emit manifest.schema.json at stable site-root path + release asset; CI regenerate+diff gate. Activate REQ-DOCS-4 (partial), REQ-DOCS-5.

M6-D4: docs-site Tier 1 content (mdBook)

mdBook skeleton + Starlight-inspired theme; mental model; human quickstart; adapter quickstart (mock-adapter); harness-contract vertical complete; shell getting-started (spt-shell-notify); stubs for remaining verticals. Activate REQ-DOCS-1/2/3.

M6-D5: agent-export + generation plumbing

llms.txt + llms-full.txt; raw .md alongside HTML; CLI ref from clap; rustdoc → Pages; source-repo CI publish job → spt-releases Pages branch; drift gates. REQ-REL-1 impl, REQ-DOCS-4/5 complete.

M6-D6: release pipeline + signing ceremony

Workspace → 0.1.0; tag-triggered build matrix both runners; cross-repo release via fine-grained token; key ceremony per ADR-0015 (primary + recovery, embed both pubkeys in VerifyPolicy trusted set, seed backups); manual SignedRelease signing. Activate REQ-REL-2/3.

M6-D7: v0.1.0 ship + flip public + acceptance run

Tag v0.1.0; ceremony; publish release; flip spt-releases public; Pages live; acceptance proof per M6-PLAN (clean-machine install, quickstarts, llms.txt + schema fetch, staged v0.1.1 verify, recovery-key verify).


CONTEXT.md already carries scanned doc tags — good. Writing D2 artifacts.


Write

#!/bin/sh
# spt installer (Linux) — served at the canonical Pages URL (ADR-0014):
#
#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
#
# [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.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/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
set -eu

REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"

die() { echo "spt-install: error: $*" >&2; exit 1; }

# ---- platform / arch -> release asset name -------------------------------
OS="$(uname -s)"
[ "$OS" = "Linux" ] || die "unsupported OS '$OS' (this script installs the Linux binary; Windows: install.ps1; macOS builds are not published yet)"
ARCH="$(uname -m)"
case "$ARCH" in
  x86_64|amd64) ASSET="spt-x86_64-linux" ;;
  *) die "no published build for architecture '$ARCH' yet (x86_64 only today)" ;;
esac

command -v curl >/dev/null 2>&1 || die "curl is required"
if command -v sha256sum >/dev/null 2>&1; then
  SHA256() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
  SHA256() { shasum -a 256 "$1" | awk '{print $1}'; }
else
  die "need sha256sum or shasum to verify the download"
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM

# ---- fetch: binary + SHA256SUMS ------------------------------------------
# [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
# lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
fetch() { # $1 = file name, $2 = dest path
  if [ -n "${SPT_INSTALL_ASSET_BASE:-}" ]; then
    case "$SPT_INSTALL_ASSET_BASE" in
      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
      *) cp "$SPT_INSTALL_ASSET_BASE/$1" "$2" ;;
    esac
  elif [ -n "${SPT_INSTALL_VERSION:-}" ]; then
    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
  else
    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
  fi
}

echo "spt-install: fetching $ASSET ..."
fetch "$ASSET" "$WORK/$ASSET"
fetch "SHA256SUMS" "$WORK/SHA256SUMS"

# ---- verify ----------------------------------------------------------------
WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
GOT="$(SHA256 "$WORK/$ASSET")"
[ "$GOT" = "$WANT" ] || die "sha256 mismatch for $ASSET (expected $WANT, got $GOT) — refusing to install"
echo "spt-install: sha256 verified."

# ---- place -----------------------------------------------------------------
# [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced atomically
# (rename over the old one); the PATH registration below is added at most once.
mkdir -p "$INSTALL_DIR"
chmod +x "$WORK/$ASSET"
mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"

# ---- user PATH registration -------------------------------------------------
PATH_NOTE=""
if [ "${SPT_INSTALL_NO_PATH:-0}" != "1" ]; then
  case ":$PATH:" in
    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
    *)
      PROFILE="$HOME/.profile"
      MARK="# added by spt installer"
      if [ ! -f "$PROFILE" ] || ! grep -F "$MARK" "$PROFILE" >/dev/null 2>&1; then
        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
      else
        PATH_NOTE="$INSTALL_DIR is registered in ~/.profile but not active in this shell."
      fi
      ;;
  esac
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}


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

#!/bin/sh
# spt installer (Linux) — served at the canonical Pages URL (ADR-0014):
#
#   curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh
#
# [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.
#
# Environment knobs (all optional):
#   SPT_INSTALL_DIR        install dir            (default: $HOME/.local/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
set -eu

REPO="${SPT_INSTALL_REPO:-SaberMage/spt-releases}"
INSTALL_DIR="${SPT_INSTALL_DIR:-$HOME/.local/bin}"

die() { echo "spt-install: error: $*" >&2; exit 1; }

# ---- platform / arch -> release asset name -------------------------------
OS="$(uname -s)"
[ "$OS" = "Linux" ] || die "unsupported OS '$OS' (this script installs the Linux binary; Windows: install.ps1; macOS builds are not published yet)"
ARCH="$(uname -m)"
case "$ARCH" in
  x86_64|amd64) ASSET="spt-x86_64-linux" ;;
  *) die "no published build for architecture '$ARCH' yet (x86_64 only today)" ;;
esac

command -v curl >/dev/null 2>&1 || die "curl is required"
if command -v sha256sum >/dev/null 2>&1; then
  SHA256() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
  SHA256() { shasum -a 256 "$1" | awk '{print $1}'; }
else
  die "need sha256sum or shasum to verify the download"
fi

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT INT TERM

# ---- fetch: binary + SHA256SUMS ------------------------------------------
# [impl->REQ-INSTALL-1] the standalone one-line script half: latest-release
# lookup -> platform asset fetch -> sha256 verify -> place -> user PATH.
fetch() { # $1 = file name, $2 = dest path
  if [ -n "${SPT_INSTALL_ASSET_BASE:-}" ]; then
    case "$SPT_INSTALL_ASSET_BASE" in
      http://*|https://*) curl -fsSL "$SPT_INSTALL_ASSET_BASE/$1" -o "$2" ;;
      *) cp "$SPT_INSTALL_ASSET_BASE/$1" "$2" ;;
    esac
  elif [ -n "${SPT_INSTALL_VERSION:-}" ]; then
    curl -fsSL "https://github.com/$REPO/releases/download/$SPT_INSTALL_VERSION/$1" -o "$2"
  else
    curl -fsSL "https://github.com/$REPO/releases/latest/download/$1" -o "$2"
  fi
}

echo "spt-install: fetching $ASSET ..."
fetch "$ASSET" "$WORK/$ASSET"
fetch "SHA256SUMS" "$WORK/SHA256SUMS"

# ---- verify ----------------------------------------------------------------
WANT="$(awk -v a="$ASSET" '$2 == a { print $1 }' "$WORK/SHA256SUMS")"
[ -n "$WANT" ] || die "SHA256SUMS has no entry for $ASSET"
GOT="$(SHA256 "$WORK/$ASSET")"
[ "$GOT" = "$WANT" ] || die "sha256 mismatch for $ASSET (expected $WANT, got $GOT) — refusing to install"
echo "spt-install: sha256 verified."

# ---- place -----------------------------------------------------------------
# [impl->REQ-INSTALL-3] idempotent re-run: the binary is replaced atomically
# (rename over the old one); the PATH registration below is added at most once.
mkdir -p "$INSTALL_DIR"
chmod +x "$WORK/$ASSET"
mv -f "$WORK/$ASSET" "$INSTALL_DIR/spt"

# ---- user PATH registration -------------------------------------------------
PATH_NOTE=""
if [ "${SPT_INSTALL_NO_PATH:-0}" != "1" ]; then
  case ":$PATH:" in
    *":$INSTALL_DIR:"*) ;; # already reachable in this shell
    *)
      PROFILE="$HOME/.profile"
      MARK="# added by spt installer"
      if [ ! -f "$PROFILE" ] || ! grep -F "$MARK" "$PROFILE" >/dev/null 2>&1; then
        printf '\n%s\nexport PATH="%s:$PATH"\n' "$MARK" "$INSTALL_DIR" >> "$PROFILE"
        PATH_NOTE="Registered $INSTALL_DIR on your PATH via ~/.profile (takes effect in new login shells)."
      else
        PATH_NOTE="$INSTALL_DIR is registered in ~/.profile but not active in this shell."
      fi
      ;;
  esac
fi

# ---- report ----------------------------------------------------------------
# Print the absolute path: until PATH refreshes, invoke it absolutely.
echo "spt-install: installed $INSTALL_DIR/spt"
"$INSTALL_DIR/spt" --version
[ -z "$PATH_NOTE" ] || {
  echo "spt-install: $PATH_NOTE"
  echo "spt-install: in this shell, run it as $INSTALL_DIR/spt (or: export PATH=\"$INSTALL_DIR:\$PATH\")"
}


# 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

function Fail([string]$msg) {
    Write-Error "spt-install: error: $msg"
    exit 1
}

$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" }
    $got = (Get-FileHash -Path $binPath -Algorithm SHA256).Hash.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
}


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

# 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

function Fail([string]$msg) {
    Write-Error "spt-install: error: $msg"
    exit 1
}

$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" }
    $got = (Get-FileHash -Path $binPath -Algorithm SHA256).Hash.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
}


Edit

function Fail([string]$msg) {
    Write-Error "spt-install: error: $msg"
    exit 1
}

# 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"
}


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)

# 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"
}

# 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

function Fail([string]$msg) {
    Write-Error "spt-install: error: $msg"
    exit 1
}

$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" }
    $got = (Get-FileHash -Path $binPath -Algorithm SHA256).Hash.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
}

+# 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.

-    Write-Error "spt-install: error: $msg"


1	//! M5-D8a notify-shell E2E — the REAL standalone shell adapter
2	//! (spt-shell-notify, its own repo: the manifest + binary are the only glue,
3	//! zero spt-core source integration) through the real `spt` binary, no
4	//! in-process shortcuts on the driven path: spawn (real gate chain) →
5	//! broker-launch of `notify-shell` → its `api bind-shell --link` handshake
6	//! onlines the perch → `spt shell cmd … notify <title> <body>` spools a
7	//! vocabulary-checked stamped frame → the binary's resident `api poll --link`
8	//! drain parses it and RENDERS — observed via `--render-file` (the native OS
9	//! call is the only line left unproven; a headless CI box has no toast
10	//! surface) → teardown kills the resident binary.
11	//!
12	//! **Env-gated** (the twohost pattern): `SPT_NOTIFY_SHELL_BIN` = absolute
13	//! path to a built `notify-shell` binary. Absent ⇒ silent skip — the CI hook
14	//! clones + builds the adapter repo and sets it; the normal sweep skips.
15	//
16	// [int->REQ-EP-5]
17	// [int->REQ-SHELL-1]
18
19	use std::path::PathBuf;
20	use std::process::{Command, Output};
21	use std::time::{Duration, Instant};
22
23	use spt_store::perch::{self, ParentHint};
24
25	/// In-process seed daemon on the isolated home (shell_e2e's pattern): no real
26	/// detached `spt daemon` ever spawns from this test.
27	fn start_inproc_daemon() {
28	    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
29	    let name = spt_daemon::seed_socket_name();
30	    std::thread::spawn(move || {
31	        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
32	    });
33	    for _ in 0..400 {
34	        if spt_daemon::is_running() {
35	            return;
36	        }
37	        std::thread::sleep(Duration::from_millis(5));
38	    }
39	    panic!("in-process seed daemon did not come up");
40	}
41
42	/// `Command::output()` with a deadline (KNOWN-HAZARDS 5.6 regression face —
43	/// see shell_e2e.rs).
44	// [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT]
45	fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
46	    let (tx, rx) = std::sync::mpsc::channel();
47	    std::thread::spawn(move || {
48	        let _ = tx.send(cmd.output());
49	    });
50	    rx.recv_timeout(deadline)
51	        .expect(
52	            "captured spt call must complete — a hang here means a detached child \
53	             inherited the capture pipe (KNOWN-HAZARDS 5.6 regression)",
54	        )
55	        .expect("run spt")
56	}
57
58	#[test]
59	fn notify_shell_renders_a_commanded_toast() {
60	    // Env gate: the adapter lives in its own repo; without a built binary

//! M5-D8a notify-shell E2E — the REAL standalone shell adapter
//! (spt-shell-notify, its own repo: the manifest + binary are the only glue,
//! zero spt-core source integration) through the real `spt` binary, no
//! in-process shortcuts on the driven path: spawn (real gate chain) →
//! broker-launch of `notify-shell` → its `api bind-shell --link` handshake
//! onlines the perch → `spt shell cmd … notify <title> <body>` spools a
//! vocabulary-checked stamped frame → the binary's resident `api poll --link`
//! drain parses it and RENDERS — observed via `--render-file` (the native OS
//! call is the only line left unproven; a headless CI box has no toast
//! surface) → teardown kills the resident binary.
//!
//! **Env-gated** (the twohost pattern): `SPT_NOTIFY_SHELL_BIN` = absolute
//! path to a built `notify-shell` binary. Absent ⇒ silent skip — the CI hook
//! clones + builds the adapter repo and sets it; the normal sweep skips.
//
// [int->REQ-EP-5]
// [int->REQ-SHELL-1]

use std::path::PathBuf;
use std::process::{Command, Output};
use std::time::{Duration, Instant};

use spt_store::perch::{self, ParentHint};

/// In-process seed daemon on the isolated home (shell_e2e's pattern): no real
/// detached `spt daemon` ever spawns from this test.
fn start_inproc_daemon() {
    let reg = std::sync::Arc::new(spt_daemon::SeedRegistry::new());
    let name = spt_daemon::seed_socket_name();
    std::thread::spawn(move || {
        let _ = spt_daemon::seedmap::serve_seed_control(&name, reg);
    });
    for _ in 0..400 {
        if spt_daemon::is_running() {
            return;
        }
        std::thread::sleep(Duration::from_millis(5));
    }
    panic!("in-process seed daemon did not come up");
}

/// `Command::output()` with a deadline (KNOWN-HAZARDS 5.6 regression face —
/// see shell_e2e.rs).
// [unit->REQ-HAZARD-DETACHED-PIPE-INHERIT]
fn output_bounded(mut cmd: Command, deadline: Duration) -> Output {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        let _ = tx.send(cmd.output());
    });
    rx.recv_timeout(deadline)
        .expect(
            "captured spt call must complete — a hang here means a detached child \
             inherited the capture pipe (KNOWN-HAZARDS 5.6 regression)",
        )
        .expect("run spt")
}

#[test]
fn notify_shell_renders_a_commanded_toast() {
    // Env gate: the adapter lives in its own repo; without a built binary


Write

//! M6-D2 installer-script E2E — the real `install.sh` / `install.ps1` (the
//! canonical one-liners served at the Pages URL, ADR-0014) run against a
//! STAGED release artifact: the freshly built workspace `spt` binary is laid
//! out exactly like a release asset (platform asset name + SHA256SUMS), the
//! matching per-OS script installs it into an isolated root, and the rung
//! finishes with the installed binary answering `spt --version` — the
//! M6-PLAN D2 CI rung.
//!
//! **Env-gated** (the twohost pattern): `SPT_INSTALL_E2E=1` runs it; absent
//! ⇒ silent skip inside the normal sweep. Hermetic: `HOME` (Linux) is a
//! temp dir, so PATH registration lands in a throwaway `.profile`; Windows
//! runs with `SPT_INSTALL_NO_PATH=1` (the user-PATH leg writes the real HKCU
//! registry — never from a test). The asset fetch uses
//! `SPT_INSTALL_ASSET_BASE=<staged dir>`, the scripts' CI/air-gap knob.
//
// [int->REQ-INSTALL-1]
// [int->REQ-INSTALL-5]

use std::path::{Path, PathBuf};
use std::process::Command;

use sha2::{Digest, Sha256};

fn gated() -> bool {
    std::env::var("SPT_INSTALL_E2E").map(|v| v == "1").unwrap_or(false)
}

fn installer_dir() -> PathBuf {
    // crates/spt -> repo root -> installer/
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../installer")
        .canonicalize()
        .expect("installer/ dir exists at the repo root")
}

#[cfg(windows)]
const ASSET: &str = "spt-x86_64-windows.exe";
#[cfg(not(windows))]
const ASSET: &str = "spt-x86_64-linux";

/// Lay the built `spt` binary out as a staged release: asset-named copy +
/// a SHA256SUMS in the exact `<hex><2 spaces><name>` shape the release
/// pipeline emits (D6).
fn stage_release(dir: &Path) {
    let bin = env!("CARGO_BIN_EXE_spt");
    let asset_path = dir.join(ASSET);
    std::fs::copy(bin, &asset_path).expect("stage the built spt binary");
    let bytes = std::fs::read(&asset_path).expect("read staged asset");
    let digest = Sha256::digest(&bytes);
    let line = format!("{:x}  {}\n", digest, ASSET);
    std::fs::write(dir.join("SHA256SUMS"), line).expect("write SHA256SUMS");
}

/// Run the per-OS install script non-interactively against the staged dir.
fn run_script(staged: &Path, install_dir: &Path, fake_home: &Path) -> std::process::Output {
    let mut cmd;
    #[cfg(windows)]
    {
        // `powershell` (5.1), not pwsh: the `irm | iex` one-liner reality.
        cmd = Command::new("powershell");
        cmd.args([
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
            installer_dir().join("install.ps1").to_str().unwrap(),
        ]);
        // Never write the real HKCU user PATH from a test.
        cmd.env("SPT_INSTALL_NO_PATH", "1");
        let _ = fake_home; // PATH isolation is the NO_PATH knob on Windows
    }
    #[cfg(not(windows))]
    {
        // `sh`, not bash: the curl-pipe target is POSIX sh (dash on Ubuntu).
        cmd = Command::new("sh");
        cmd.arg(installer_dir().join("install.sh"));
        // PATH registration lands in this throwaway $HOME/.profile.
        cmd.env("HOME", fake_home);
    }
    cmd.env("SPT_INSTALL_ASSET_BASE", staged)
        .env("SPT_INSTALL_DIR", install_dir)
        .output()
        .expect("run install script")
}

fn installed_bin(install_dir: &Path) -> PathBuf {
    if cfg!(windows) {
        install_dir.join("spt.exe")
    } else {
        install_dir.join("spt")
    }
}

/// The whole D2 rung: staged release -> script install -> `spt --version`,
/// then the idempotent re-run (REQ-INSTALL-3) and the sha-mismatch refusal
/// (the REQ-INSTALL-5 verify gate actually gating).
#[test]
fn install_script_against_staged_release() {
    if !gated() {
        eprintln!("install_e2e: SPT_INSTALL_E2E unset - skipping");
        return;
    }

    let tmp = tempfile::tempdir().expect("tempdir");
    let staged = tmp.path().join("staged");
    let root = tmp.path().join("bin");
    let home = tmp.path().join("home");
    std::fs::create_dir_all(&staged).unwrap();
    std::fs::create_dir_all(&home).unwrap();
    stage_release(&staged);

    // ---- first install ----
    let out = run_script(&staged, &root, &home);
    assert!(
        out.status.success(),
        "install script failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let bin = installed_bin(&root);
    assert!(bin.is_file(), "installed binary missing at {}", bin.display());

    // The acceptance shape: the *installed* binary answers --version.
    let ver = Command::new(&bin).arg("--version").output().expect("run installed spt");
    assert!(ver.status.success(), "installed spt --version failed");
    let ver_out = String::from_utf8_lossy(&ver.stdout);
    assert!(
        ver_out.contains("spt"),
        "--version output unexpected: {ver_out}"
    );

    // Linux: the PATH leg registered the install dir in the throwaway profile.
    #[cfg(not(windows))]
    {
        let profile = std::fs::read_to_string(home.join(".profile")).expect("profile written");
        assert!(
            profile.contains(root.to_str().unwrap()),
            "install dir not registered in .profile:\n{profile}"
        );
    }

    // ---- idempotent re-run (REQ-INSTALL-3) ----
    // [int->REQ-INSTALL-3]
    let out2 = run_script(&staged, &root, &home);
    assert!(
        out2.status.success(),
        "idempotent re-run failed:\nstderr: {}",
        String::from_utf8_lossy(&out2.stderr)
    );
    assert!(installed_bin(&root).is_file(), "binary gone after re-run");
    #[cfg(not(windows))]
    {
        // PATH registration is added at most once.
        let profile = std::fs::read_to_string(home.join(".profile")).unwrap();
        let hits = profile.matches("# added by spt installer").count();
        assert_eq!(hits, 1, "PATH block duplicated on re-run:\n{profile}");
    }

    // ---- sha mismatch refuses (the verify gate is real) ----
    let evil = tmp.path().join("evil");
    std::fs::create_dir_all(&evil).unwrap();
    stage_release(&evil);
    std::fs::write(
        evil.join("SHA256SUMS"),
        format!("{}  {}\n", "0".repeat(64), ASSET),
    )
    .unwrap();
    let root2 = tmp.path().join("bin2");
    let out3 = run_script(&evil, &root2, &home);
    assert!(
        !out3.status.success(),
        "script must refuse a sha256 mismatch"
    );
    assert!(
        !installed_bin(&root2).exists(),
        "tampered binary must never be placed"
    );
}


File created successfully at: C:\Users\decid\Documents\projects\spt-core\crates\spt\tests\install_e2e.rs (file state is current in your context — no need to Read it back)

//! M6-D2 installer-script E2E — the real `install.sh` / `install.ps1` (the
//! canonical one-liners served at the Pages URL, ADR-0014) run against a
//! STAGED release artifact: the freshly built workspace `spt` binary is laid
//! out exactly like a release asset (platform asset name + SHA256SUMS), the
//! matching per-OS script installs it into an isolated root, and the rung
//! finishes with the installed binary answering `spt --version` — the
//! M6-PLAN D2 CI rung.
//!
//! **Env-gated** (the twohost pattern): `SPT_INSTALL_E2E=1` runs it; absent
//! ⇒ silent skip inside the normal sweep. Hermetic: `HOME` (Linux) is a
//! temp dir, so PATH registration lands in a throwaway `.profile`; Windows
//! runs with `SPT_INSTALL_NO_PATH=1` (the user-PATH leg writes the real HKCU
//! registry — never from a test). The asset fetch uses
//! `SPT_INSTALL_ASSET_BASE=<staged dir>`, the scripts' CI/air-gap knob.
//
// [int->REQ-INSTALL-1]
// [int->REQ-INSTALL-5]

use std::path::{Path, PathBuf};
use std::process::Command;

use sha2::{Digest, Sha256};

fn gated() -> bool {
    std::env::var("SPT_INSTALL_E2E").map(|v| v == "1").unwrap_or(false)
}

fn installer_dir() -> PathBuf {
    // crates/spt -> repo root -> installer/
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../../installer")
        .canonicalize()
        .expect("installer/ dir exists at the repo root")
}

#[cfg(windows)]
const ASSET: &str = "spt-x86_64-windows.exe";
#[cfg(not(windows))]
const ASSET: &str = "spt-x86_64-linux";

/// Lay the built `spt` binary out as a staged release: asset-named copy +
/// a SHA256SUMS in the exact `<hex><2 spaces><name>` shape the release
/// pipeline emits (D6).
fn stage_release(dir: &Path) {
    let bin = env!("CARGO_BIN_EXE_spt");
    let asset_path = dir.join(ASSET);
    std::fs::copy(bin, &asset_path).expect("stage the built spt binary");
    let bytes = std::fs::read(&asset_path).expect("read staged asset");
    let digest = Sha256::digest(&bytes);
    let line = format!("{:x}  {}\n", digest, ASSET);
    std::fs::write(dir.join("SHA256SUMS"), line).expect("write SHA256SUMS");
}

/// Run the per-OS install script non-interactively against the staged dir.
fn run_script(staged: &Path, install_dir: &Path, fake_home: &Path) -> std::process::Output {
    let mut cmd;
    #[cfg(windows)]
    {
        // `powershell` (5.1), not pwsh: the `irm | iex` one-liner reality.
        cmd = Command::new("powershell");
        cmd.args([
            "-NoProfile",
            "-ExecutionPolicy",
            "Bypass",
            "-File",
            installer_dir().join("install.ps1").to_str().unwrap(),
        ]);
        // Never write the real HKCU user PATH from a test.
        cmd.env("SPT_INSTALL_NO_PATH", "1");
        let _ = fake_home; // PATH isolation is the NO_PATH knob on Windows
    }
    #[cfg(not(windows))]
    {
        // `sh`, not bash: the curl-pipe target is POSIX sh (dash on Ubuntu).
        cmd = Command::new("sh");
        cmd.arg(installer_dir().join("install.sh"));
        // PATH registration lands in this throwaway $HOME/.profile.
        cmd.env("HOME", fake_home);
    }
    cmd.env("SPT_INSTALL_ASSET_BASE", staged)
        .env("SPT_INSTALL_DIR", install_dir)
        .output()
        .expect("run install script")
}

fn installed_bin(install_dir: &Path) -> PathBuf {
    if cfg!(windows) {
        install_dir.join("spt.exe")
    } else {
        install_dir.join("spt")
    }
}

/// The whole D2 rung: staged release -> script install -> `spt --version`,
/// then the idempotent re-run (REQ-INSTALL-3) and the sha-mismatch refusal
/// (the REQ-INSTALL-5 verify gate actually gating).
#[test]
fn install_script_against_staged_release() {
    if !gated() {
        eprintln!("install_e2e: SPT_INSTALL_E2E unset - skipping");
        return;
    }

    let tmp = tempfile::tempdir().expect("tempdir");
    let staged = tmp.path().join("staged");
    let root = tmp.path().join("bin");
    let home = tmp.path().join("home");
    std::fs::create_dir_all(&staged).unwrap();
    std::fs::create_dir_all(&home).unwrap();
    stage_release(&staged);

    // ---- first install ----
    let out = run_script(&staged, &root, &home);
    assert!(
        out.status.success(),
        "install script failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    let bin = installed_bin(&root);
    assert!(bin.is_file(), "installed binary missing at {}", bin.display());

    // The acceptance shape: the *installed* binary answers --version.
    let ver = Command::new(&bin).arg("--version").output().expect("run installed spt");
    assert!(ver.status.success(), "installed spt --version failed");
    let ver_out = String::from_utf8_lossy(&ver.stdout);
    assert!(
        ver_out.contains("spt"),
        "--version output unexpected: {ver_out}"
    );

    // Linux: the PATH leg registered the install dir in the throwaway profile.
    #[cfg(not(windows))]
    {
        let profile = std::fs::read_to_string(home.join(".profile")).expect("profile written");
        assert!(
            profile.contains(root.to_str().unwrap()),
            "install dir not registered in .profile:\n{profile}"
        );
    }

    // ---- idempotent re-run (REQ-INSTALL-3) ----
    // [int->REQ-INSTALL-3]
    let out2 = run_script(&staged, &root, &home);
    assert!(
        out2.status.success(),
        "idempotent re-run failed:\nstderr: {}",
        String::from_utf8_lossy(&out2.stderr)
    );
    assert!(installed_bin(&root).is_file(), "binary gone after re-run");
    #[cfg(not(windows))]
    {
        // PATH registration is added at most once.
        let profile = std::fs::read_to_string(home.join(".profile")).unwrap();
        let hits = profile.matches("# added by spt installer").count();
        assert_eq!(hits, 1, "PATH block duplicated on re-run:\n{profile}");
    }

    // ---- sha mismatch refuses (the verify gate is real) ----
    let evil = tmp.path().join("evil");
    std::fs::create_dir_all(&evil).unwrap();
    stage_release(&evil);
    std::fs::write(
        evil.join("SHA256SUMS"),
        format!("{}  {}\n", "0".repeat(64), ASSET),
    )
    .unwrap();
    let root2 = tmp.path().join("bin2");
    let out3 = run_script(&evil, &root2, &home);
    assert!(
        !out3.status.success(),
        "script must refuse a sha256 mismatch"
    );
    assert!(
        !installed_bin(&root2).exists(),
        "tampered binary must never be placed"
    );
}


Edit

[dev-dependencies]
tempfile = "3"
toml = "0.8"

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"


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

[dev-dependencies]
tempfile = "3"
toml = "0.8"
# install_e2e stages the built binary as a release asset (SHA256SUMS) for the
# D2 installer-script rung — same sha2 the workspace already carries.
sha2 = "0.10"

[package]
name = "spt"
version = "0.0.0"
edition = "2021"
publish = false
description = "spt-core CLI: the single `spt` binary. Thin command layer over spt-msg (send/ring/ready/poll/list/stop/whoami) + the harness-contract `api` surface (spt-runtime)."

[[bin]]
name = "spt"
path = "src/main.rs"

[dependencies]
spt-proto = { path = "../spt-proto" }
spt-store = { path = "../spt-store" }
spt-msg = { path = "../spt-msg" }
spt-runtime = { path = "../spt-runtime" }
spt-live = { path = "../spt-live" }
spt-daemon = { path = "../spt-daemon" }
# The reference binary ships the WAN transport on by default (ADR-0002). D2g reuses its
# RFC-6238 TotpSeed (spt_net::net::pairing::totp) to render a subnet's local pairing
# code — reused, not reimplemented, so the CLI code matches the ceremony's exactly.
spt-net = { path = "../spt-net" }
clap = { version = "4", features = ["derive"] }
# The WAN send leg (D9-1-4) serializes the dial address + reads registry
# snapshots - same pinned shape as spt-daemon's.
serde_json = { version = "1", features = ["preserve_order"] }

# OS privilege-elevation probe for the node-bound code fetch (REQ-PAIR-6). Only the
# binary pulls the OS deps; the core stays dep-minimal. Matches the workspace's existing
# libc/windows-sys usage (spt-msg, spt-store) rather than the heavyweight `windows`.
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = [
    "Win32_Foundation",
    "Win32_Security",
    "Win32_System_Threading",
] }

[dev-dependencies]
tempfile = "3"
toml = "0.8"

+# install_e2e stages the built binary as a release asset (SHA256SUMS) for the

+# D2 installer-script rung — same sha2 the workspace already carries.


# ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----

      # ---- M6-D2 installer-script rung ----
      # Runs the real install.sh / install.ps1 against a STAGED release
      # artifact (the just-built workspace binary laid out asset-name +
      # SHA256SUMS) on the matching runner, finishing with the installed
      # binary's `spt --version`. Env-gated: the normal sweep silent-skips
      # the same test when SPT_INSTALL_E2E is unset. Hermetic — temp HOME on
      # Linux, SPT_INSTALL_NO_PATH on Windows (never writes the runner's
      # real user PATH).
      - name: Installer E2E (staged-release rung)
        run: cargo test -p spt --test install_e2e -- --nocapture
        env:
          SPT_INSTALL_E2E: "1"

      # ---- D9-3 dormancy-budget measurement (ADR-0003 red-team #9) ----