=== _common.sh ===
# shellcheck shell=sh
# sptc hook common helpers — sourced by each hook wrapper.
# DRAFT (pending throwaway-CC validation: Windows-shell, env-file timing, whoami-in-hook).
# spt-core is harness-agnostic; this is the CC adapter glue that maps CC hook input -> `spt api`.

ADAPTER=claude-spt

# Resolve the spt binary: PATH first (post-bootstrap), then known install locations.
spt_bin() {
  if command -v spt >/dev/null 2>&1; then printf 'spt'; return 0; fi
  for p in \
    "$HOME/.local/bin/spt" \
    "$LOCALAPPDATA/spt-core/bin/spt.exe" \
    "$HOME/AppData/Local/spt-core/bin/spt.exe"; do
    [ -x "$p" ] && { printf '%s' "$p"; return 0; }
  done
  printf 'spt'  # last resort; caller tolerates failure
}

# Extract a top-level string field from a flat JSON object on stdin payload ($1=json, $2=key).
# CC hook input is a flat object for the fields we need (session_id, source, prompt, agent_id).
json_str() {
  printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}

# Resolve this session's perch id via spt whoami (off $OWL_SESSION_ID / $SPT_AGENT_ID).
# Empty => no perch yet (session never readied) => caller no-ops.
sptc_self_id() {
  _sid="$1"; _spt="$(spt_bin)"
  OWL_SESSION_ID="${OWL_SESSION_ID:-$_sid}" "$_spt" whoami 2>/dev/null | head -n1
}

# Decode an spt envelope body to plain text: literal `<br>` -> newline, then HTML entities
# (< > " then & LAST, to avoid double-decoding). Exactly the live-agent
# body-parsing rule (spt-proto::event, ADR-0001 grammar).
sptc_unescape() {
  printf '%s' "$1" | sed \
    -e 's/
/\
/g' \
    -e 's/</</g' -e 's/>/>/g' -e 's/"/"/g' -e 's/&/\&/g'
}

# Render an `api poll` drain ($1) for CC. Canonical format (ADR-0020): every message is a
# self-delimiting `<EVENT type="msg" from="<sender>">body</EVENT>` envelope (spt-proto::event) —
# the same grammar the live listener emits. Multi-message drains split cleanly on `</EVENT>`.
# Sender is preserved as `from=` (reply-correlation). NOTE: targets canonical <EVENT>; the current
# 0.6.0 binary still emits a `__REPLY_TO__` relic at the poll surface until the REQ-MSG-ENVELOPE
# refactor lands (ADR-0020) — finalize/validate against poll only post-refactor. [unit->REQ-UPS-INJECTION]
render_frames() {
  _in="$1"
  [ -z "$_in" ] && return 0
  # Normalise to one <EVENT>…</EVENT> per line (body newlines are
-escaped, so each envelope
  # is single-line), then parse each.
  printf '%s' "$_in" | sed 's#</EVENT>#</EVENT>\
#g' | while IFS= read -r _ev; do
    case "$_ev" in
      *"<EVENT"*"</EVENT>"*) ;;
      *) continue ;;
    esac
    _sender=$(printf '%s' "$_ev" | sed -n 's/.*<EVENT[^>]* from="\([^"]*\)".*/\1/p')
    _raw=$(printf '%s' "$_ev" | sed -n 's#.*<EVENT[^>]*>\(.*\)</EVENT>.*#\1#p')
    _body=$(sptc_unescape "$_raw")
    if [ -n "$_sender" ]; then
      printf '<sptc_messages from="%s">\n%s\n</sptc_messages>\n' "$_sender" "$_body"
    else
      printf '<sptc_messages>\n%s\n</sptc_messages>\n' "$_body"
    fi
  done
}
=== session-start.sh ===
#!/bin/sh
# SessionStart: ensure spt-core installed, seed the perch (or rebind on clear/compact),
# and persist session env so whoami + per-prompt hooks resolve. Non-blocking (never `listen`).
# [impl->REQ-DIST-HOOKS-API]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
src=$(json_str "$input" source)

# Invisible-installer: bootstrap spt-core if absent (no-op when present).
sh "$CLAUDE_PLUGIN_ROOT/bootstrap.sh" >/dev/null 2>&1 || true
SPT=$(spt_bin)

case "$src" in
  clear|compact)
    id=$(sptc_self_id "$sid")
    [ -n "$id" ] && "$SPT" api --adapter "$ADAPTER" boundary "$src" "$id" \
      --to-session-id "$sid" >/dev/null 2>&1 || true
    ;;
  *)
    "$SPT" api --adapter "$ADAPTER" seed --pid "$PPID" --session-id "$sid" >/dev/null 2>&1 || true
    ;;
esac

# Persist for the rest of the session (whoami + per-prompt hooks read these).
if [ -n "$CLAUDE_ENV_FILE" ]; then
  {
    printf 'OWL_SESSION_ID=%s\n' "$sid"
    printf 'SPT_ADAPTER=%s\n' "$ADAPTER"
  } >> "$CLAUDE_ENV_FILE"
fi
exit 0
=== user-prompt-submit.sh ===
#!/bin/sh
# UserPromptSubmit: drain delivered messages (the hook-injection channel, L149) and surface
# them to CC as additionalContext. Parses the canonical self-delimiting <EVENT> envelope
# (ADR-0020), so multi-message drains split cleanly on </EVENT>. [impl->REQ-UPS-INJECTION]
. "$CLAUDE_PLUGIN_ROOT/hooks/_common.sh"

input=$(cat)
sid=$(json_str "$input" session_id)
id=$(sptc_self_id "$sid")
[ -z "$id" ] && exit 0   # no perch (session not readied) -> nothing to deliver

SPT=$(spt_bin)
frames=$("$SPT" api --adapter "$ADAPTER" poll "$id" --session-id "$sid" 2>/dev/null)
[ -z "$frames" ] && exit 0

# Format for CC, preserving the sender (reply-correlation: ADR-0009/0012). render_frames parses
# the self-delimiting <EVENT> envelope (ADR-0020) — multi-message drains split on </EVENT>.
# UserPromptSubmit: stdout is added to the prompt context. (JSON additionalContext shape is an
# alternative — to confirm in throwaway validation which CC accepts here.)
render_frames "$frames"
exit 0


Resend message with correct reply-to address


Architecture clear. Checking SCOPE acceptance lines + `claude`/`spt` env for disposable identity, then write JIT plan.


Bash

sed -n '118,135p' SCOPE.md; echo "=== claude on PATH? ==="; command -v claude || echo "NO claude"; echo "=== spt env/home ==="; spt --help 2>&1 | grep -i -- '--home\|SPT_HOME\|--data\|home\|env' | head; echo "---whoami help---"; spt whoami --help 2>&1 | head -20

SCOPE acceptance + claude/spt identity env


Question the premise: scope elevation to only steps that need it (service-install /
firewall / privileged-port) — subnet-create itself likely unprivileged.

## CI model

**LOCKED.** Wholly **agent-driven, autonomous, no LLM-in-the-loop**, on the existing
Win+Linux fleet (hfenduleam + kitsubito). GH runners DROPPED.
- Gates (build, unit, `traceable-reqs check`, manifest-schema) = deterministic scripts.
- Acceptance = scripted orchestration spawning real `claude`/headless sessions as the
  **system-under-test** (LLM is SUT, never the runner); assert spt-state/digest output.
- **Reporting bus = legacy spt** (`$OWL send` CI progress to responsible agent) — dogfoods
  the product as its own CI nervous system.
- Rationale: a stock GH runner physically can't run Claude Code (auth/interactivity);
  only a real harness on the fleet reaches the acceptance bar. spt-core's GH-runner
  justification (heavy multi-platform Rust + signed releases + two-host net) doesn't carry
  — the adapter binary is thin glue and delegates releases/signing to spt.exe.
- **Trigger (LOCKED):** git post-push hook → `$OWL send` pings a fleet runner-agent → runs
  gates → reports over spt. Manual "run gates" = fallback. (Polling rejected: latency + waste.)
=== claude on PATH? ===
/c/Users/decid/.local/bin/claude
=== spt env/home ===
---whoami help---
Print this session's own perch id.

Resolved from $OWL_SESSION_ID / $SPT_AGENT_ID.

Usage: spt.exe whoami

Options:
  -h, --help
          Print help (see a summary with '-h')


spt --help 2>&1 | head -50; echo "=== env grep ==="; spt --help 2>&1 | grep -iE 'SPT_|OWL_|--root|--dir|store|home'

spt global help for data-dir/home env


spt — a harness-independent core for an agent ecosystem: inter-agent messaging, live-agent
lifecycle, terminal hosting, P2P networking, seamless self-update. Docs:
https://sabermage.github.io/spt-releases

Usage: spt.exe <COMMAND>

User commands:
  adapter   Adapter registration: what this node can drive/launch
  daemon    The per-machine daemon: run, stop, or read node status
  grant     Consent grant store: gated capabilities held on this node
  help      Print this message or the help of the given subcommand(s)
  notif     Inspect and acknowledge notifications
  subnet    Subnet membership: status, create, show-code
  update    Self-update operations

Agent commands:
  api       Harness-contract inbound surface (hook entry points)
  endpoint  Endpoint operations: list, lifecycle, fork, digest, access
  how-to    Task-oriented instructions for agents: `how-to <topic>`
  ready     Become reachable: register the perch and listen (blocks)
  ring      Send and block for a reply (body read from stdin)
  send      Send a message (body read from stdin); fire-and-forget
  shell     Shell instances: mint, list, drive, tear down owned surfaces
  whoami    Print this session's own perch id

Options:
  -h, --help     Print help
  -V, --version  Print version
=== env grep ===
  grant     Consent grant store: gated capabilities held on this node


`claude` present. Isolation = distinct **identity** (`SPT_AGENT_ID=sptc-ci-<n>`) — that's the documented name-keyed knob; operator's "SPT_HOME" not in public surface, so identity-env is the reliable guard. Writing JIT plan.


Write

# CI-ACCEPTANCE — JIT plan (slice 1)

> Activates **REQ-CI-ACCEPTANCE** (doc→impl) and **REQ-HAZARD-PERCH-COLLISION** (unit).
> Seeds the real-`claude`-as-SUT acceptance lane. Harness-agnostic POSIX sh; runs on the fleet.

## Scope (this slice)

Scripted orchestration that spawns a **real `claude` session as the system-under-test**, wired
with the sptc UserPromptSubmit hook, and asserts a deterministic **side-effect (digest marker)** —
proving the harness-contract hook entry point fires inside a real harness. The LLM is the SUT; the
orchestration is deterministic and never judges model text.

Deliberately NARROW for slice 1: assert UPS-hook-fires-in-real-claude via a hook-written digest
file (not the spt bus, not model output) — isolates the adapter-glue contract from bus/auth flake.
Bus-delivery acceptance (poll→additionalContext over real spt) is a later slice, gated on
REQ-MSG-ENVELOPE (doyle/todlando).

## Hard invariant (headline — perch collision)

The acceptance harness's whole job is spawning nested `claude`s. A nested session that resolves the
SAME perch id as the live operator agent (`perri`) **tears down the operator's perch + poll stream**
(name-keyed, last-establish-wins; diagnosed 2026-06-15, operator). So:

> **Every nested SUT runs under a DISPOSABLE identity** (`SPT_AGENT_ID=sptc-ci-<n>`), never a live
> agent name. The harness MUST set it; a unit test proves the harness never emits a live id and
> always overrides `SPT_AGENT_ID`/`OWL_SESSION_ID`.

→ new KNOWN-HAZARDS entry 2 + `REQ-HAZARD-PERCH-COLLISION` (`["unit"]`).

## Tasks

1. `traceable-reqs.toml`: add `REQ-HAZARD-PERCH-COLLISION` (`["unit"]`); flip `REQ-CI-ACCEPTANCE`
   → `["doc","impl"]`. Add `ci/acceptance` to `[scan].roots`.
2. `docs/KNOWN-HAZARDS.md`: entry **2. Perch collision** (failure/invariant/mapping/cite).
3. `ci/acceptance/lib.sh`: `sptc_ci_identity` (disposable id + isolated env), `sptc_ci_mkproject`
   (temp project + `.claude/settings.json` + digest-writing UPS hook fixture), `sptc_ci_assert`.
4. `ci/acceptance/run-acceptance.sh`: orchestrator — scaffold → spawn `claude -p` under disposable
   id → assert digest marker → teardown. **Env-gated**: skips cleanly (rc 0) unless
   `SPTC_ACCEPTANCE=1` and `claude` on PATH, so deterministic gates stay green auth-free.
   Carries the `int->REQ-CI-ACCEPTANCE` evidence tag (execution = slow lane).
5. `tests/acceptance-harness.sh`: UNIT test of the deterministic harness pieces — identity
   isolation (perch-collision guard → `unit->REQ-HAZARD-PERCH-COLLISION`), scaffold shape, assert
   helper, env-gate skip. No real `claude` spawned. Wired into `run-gates.sh` unit loop already.
6. `docs/CI.md`: note (a) **pre-push is correct** (git has no client-side post-push); (b) the
   disposable-identity acceptance invariant.

## Gate

`sh ci/run-gates.sh` ⇒ PASS · `traceable-reqs check` green. Commit atomically, `Co-authored by: perri`.

## Follow-up (not this slice)

Attempt one real `claude -p` acceptance run under disposable id; if green, add `int` tag to
`run-acceptance.sh` evidence + flip `REQ-CI-ACCEPTANCE` to include `"int"`; report byte result to
doyle. Hold `int` until actually demonstrated (activate-don't-pre-fail).


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

# CI-ACCEPTANCE — JIT plan (slice 1)

> Activates **REQ-CI-ACCEPTANCE** (doc→impl) and **REQ-HAZARD-PERCH-COLLISION** (unit).
> Seeds the real-`claude`-as-SUT acceptance lane. Harness-agnostic POSIX sh; runs on the fleet.

## Scope (this slice)

Scripted orchestration that spawns a **real `claude` session as the system-under-test**, wired
with the sptc UserPromptSubmit hook, and asserts a deterministic **side-effect (digest marker)** —
proving the harness-contract hook entry point fires inside a real harness. The LLM is the SUT; the
orchestration is deterministic and never judges model text.

Deliberately NARROW for slice 1: assert UPS-hook-fires-in-real-claude via a hook-written digest
file (not the spt bus, not model output) — isolates the adapter-glue contract from bus/auth flake.
Bus-delivery acceptance (poll→additionalContext over real spt) is a later slice, gated on
REQ-MSG-ENVELOPE (doyle/todlando).

## Hard invariant (headline — perch collision)

The acceptance harness's whole job is spawning nested `claude`s. A nested session that resolves the
SAME perch id as the live operator agent (`perri`) **tears down the operator's perch + poll stream**
(name-keyed, last-establish-wins; diagnosed 2026-06-15, operator). So:

> **Every nested SUT runs under a DISPOSABLE identity** (`SPT_AGENT_ID=sptc-ci-<n>`), never a live
> agent name. The harness MUST set it; a unit test proves the harness never emits a live id and
> always overrides `SPT_AGENT_ID`/`OWL_SESSION_ID`.

→ new KNOWN-HAZARDS entry 2 + `REQ-HAZARD-PERCH-COLLISION` (`["unit"]`).

## Tasks

1. `traceable-reqs.toml`: add `REQ-HAZARD-PERCH-COLLISION` (`["unit"]`); flip `REQ-CI-ACCEPTANCE`
   → `["doc","impl"]`. Add `ci/acceptance` to `[scan].roots`.
2. `docs/KNOWN-HAZARDS.md`: entry **2. Perch collision** (failure/invariant/mapping/cite).
3. `ci/acceptance/lib.sh`: `sptc_ci_identity` (disposable id + isolated env), `sptc_ci_mkproject`
   (temp project + `.claude/settings.json` + digest-writing UPS hook fixture), `sptc_ci_assert`.
4. `ci/acceptance/run-acceptance.sh`: orchestrator — scaffold → spawn `claude -p` under disposable
   id → assert digest marker → teardown. **Env-gated**: skips cleanly (rc 0) unless
   `SPTC_ACCEPTANCE=1` and `claude` on PATH, so deterministic gates stay green auth-free.
   Carries the `int->REQ-CI-ACCEPTANCE` evidence tag (execution = slow lane).
5. `tests/acceptance-harness.sh`: UNIT test of the deterministic harness pieces — identity
   isolation (perch-collision guard → `unit->REQ-HAZARD-PERCH-COLLISION`), scaffold shape, assert
   helper, env-gate skip. No real `claude` spawned. Wired into `run-gates.sh` unit loop already.
6. `docs/CI.md`: note (a) **pre-push is correct** (git has no client-side post-push); (b) the
   disposable-identity acceptance invariant.

## Gate

`sh ci/run-gates.sh` ⇒ PASS · `traceable-reqs check` green. Commit atomically, `Co-authored by: perri`.

## Follow-up (not this slice)

Attempt one real `claude -p` acceptance run under disposable id; if green, add `int` tag to
`run-acceptance.sh` evidence + flip `REQ-CI-ACCEPTANCE` to include `"int"`; report byte result to
doyle. Hold `int` until actually demonstrated (activate-don't-pre-fail).


Now implement. Start with traceable-reqs + KNOWN-HAZARDS, then the harness code + test.


Edit

roots = ["plugin", "ci", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

roots = ["plugin", "ci", "ci/acceptance", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]


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

# traceable-reqs manifest for spt-claude-code — SEED.
#
# Authoritative requirement registry: every requirement and every KNOWN-HAZARDS
# invariant lives here as a REQ-* id (see docs/TRACEABILITY.md for the full contract).
#
# SEED YOUR REQs FROM SCOPE.md "Parity-trim" — the DROP / TRANSFORM / KEEP / ADD buckets are
# the parity reference. Mint a REQ-* per surface you port/transform/add as you START each
# milestone (NOT up front). The registry below is intentionally EMPTY (commented shapes only).
#
# ACQUIRE the CLI (it is a released binary, not a crate dependency):
#   gh release download --repo BigscreenVR/traceable-reqs \
#     --pattern '*<your-platform>' --output traceable-reqs   # then chmod +x
# (see INSTANTIATE.md for per-platform patterns + CI wiring.) Validate this seed against
# the installed CLI before relying on it: `traceable-reqs check`.
#
# THE FOUR-STAGE CONTRACT: every requirement is traced from
#   doc  (prose/design)  ->  impl (production code)  ->  unit (unit test)  ->  int (cross-process/node)
# Tag evidence in the SAME commit, on/above the real evidence:
#   // [impl->REQ-FOO]   // [unit->REQ-FOO]   <!-- [doc->REQ-FOO] -->
#
# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`) so `check`
# stays green pre-code. A milestone ACTIVATES its requirements by setting their real
# `required_stages` when work on them begins. Deferred items stay [] until promoted.
# Never declare the full end-state stage list up front — activate incrementally as evidence
# lands (add "int" at the final integrating wave).

[scan]
# Every evidence location. A missing root makes evidence SILENTLY vanish from the trace —
# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`
# the moment those dirs are created (skeleton milestone, body #2) — until they exist a
# nonexistent root is a hard manifest_error, so only list what is on disk now.
roots = ["plugin", "ci", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

[policy]
# Default policy applied at activation: doc + impl + unit. Networking/lifecycle/cross-node
# reqs add "int". Until a req is activated, its per-req `required_stages = []` keeps it out
# of the coverage gate.
required_stages = []

# ───────────────────────────── Requirements ─────────────────────────────────
# Add requirements as you scope milestones, seeded from SCOPE.md "Parity-trim". Shape:
#
# [[requirements]]
# id = "REQ-EXAMPLE-1"
# title = "One-line statement of the requirement"
# required_stages = []  # activate (e.g. ["doc","impl","unit"]) when the milestone starts
#
# KNOWN-HAZARDS invariants are first-class requirements — one REQ-HAZARD-* per invariant,
# each requiring at least "unit" (add "int" where cross-process/cross-node):
#
# [[requirements]]
# id = "REQ-HAZARD-EXAMPLE"
# title = "The invariant, stated so a test can prove it"
# required_stages = []

# ── Milestone: SKELETON (thin cplugs plugin) — minted at kickoff, per-milestone (not
# the whole registry up front). All inactive (required_stages = []) so `check` stays
# green until body #2 lands evidence and flips each to ["doc"] / ["doc","impl"] etc.
# Seeded from SCOPE.md "Parity-trim" (Distribution + ADD buckets).

[[requirements]]
id = "REQ-DIST-PLUGIN-SKELETON"
title = "Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic"
required_stages = ["doc"]  # doc: ADR-0001. impl held until hooks.json lands (gap-F-001 #2).

[[requirements]]
id = "REQ-DIST-BOOTSTRAP-INSTALL"
title = "SessionStart bootstrap installs spt-core when absent (invisible-installer pattern)"
required_stages = ["doc", "impl"]  # doc: ADR-0001. impl: plugin/sptc/bootstrap.{sh,ps1}.

[[requirements]]
id = "REQ-DIST-HOOKS-API"
title = "hooks.json delegates to `spt api` rather than carrying adapter logic"
required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int (real-CC e2e) held for throwaway validation.

[[requirements]]
id = "REQ-DIST-MANIFEST-SCHEMA"
title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
required_stages = []

[[requirements]]
id = "REQ-UPS-INJECTION"
title = "UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"
required_stages = ["doc", "impl", "unit"]  # doc: ADR-0002. impl: user-prompt-submit.sh. unit: render_frames test. int (UPS-fires + multi-frame post-F-002) held for throwaway validation.

# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +
# spt reporting bus. Seeded from SCOPE.md "CI model" + docs/CI.md. Real-claude acceptance is a
# later slice (doc-only now). All harness-agnostic shell; runs on the Win+Linux fleet.

[[requirements]]
id = "REQ-CI-GATES"
title = "Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command"
required_stages = ["doc", "impl", "unit"]

[[requirements]]
id = "REQ-CI-MANUAL"
title = "A manual 'run gates' command always exists — the same gate scripts runnable by hand on any fleet host"
required_stages = ["doc", "impl"]

[[requirements]]
id = "REQ-CI-TRIGGER"
title = "A git push hook fires the gates: pings a fleet runner-agent over spt rather than polling"
required_stages = ["doc", "impl"]

[[requirements]]
id = "REQ-CI-BUS"
title = "CI progress/results report over spt messaging (dogfood the product as its own CI nervous system)"
required_stages = ["doc", "impl"]

[[requirements]]
id = "REQ-CI-OWL-DISCOVERY"
title = "The spt messaging (bus) binary is located robustly at run time (per-version plugins path / PATH / configured) — never a hard-coded versioned path"
required_stages = ["doc", "impl", "unit"]

[[requirements]]
id = "REQ-CI-ACCEPTANCE"
title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"
required_stages = ["doc"]

[[requirements]]
id = "REQ-HAZARD-MSYS-PATHCONV"
title = "Hook wrappers read the CC payload from stdin, never from a /-leading positional argv (Git-Bash/MSYS path-mangles those on Windows)"
required_stages = ["unit"]  # unit: tests/msys-hazard.sh. Confirmed live 2026-06-15 (UPS validation run A).

[[requirements]]
id = "REQ-SKILL-VERSION"
title = "/spt:version reports the spt-core-tracked manifest/binary version (the version-of-truth, not the marketplace skeleton version)"
required_stages = []

-roots = ["plugin", "ci", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

+roots = ["plugin", "ci", "ci/acceptance", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]


id = "REQ-CI-ACCEPTANCE"
title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"
required_stages = ["doc"]

id = "REQ-CI-ACCEPTANCE"
title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"
required_stages = ["doc", "impl"]  # doc: docs/CI.md. impl: ci/acceptance/{lib,run-acceptance}.sh + harness unit. int (real-claude spawn) held until demonstrated under disposable id (CI-ACCEPTANCE-PLAN follow-up).

[[requirements]]
id = "REQ-HAZARD-PERCH-COLLISION"
title = "The acceptance harness spawns every nested claude SUT under a DISPOSABLE identity (SPT_AGENT_ID=sptc-ci-<n>), never a live agent's perch id — a colliding id tears down the live agent's perch + poll stream (name-keyed, last-establish-wins)"
required_stages = ["unit"]  # unit: tests/acceptance-harness.sh. Diagnosed 2026-06-15 (operator): nested claude loading the spt plugin established a perch under "perri" and displaced the live poll stream.


id = "REQ-CI-ACCEPTANCE"
title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"
required_stages = ["doc", "impl"]  # doc: docs/CI.md. impl: ci/acceptance/{lib,run-acceptance}.sh + harness unit. int (real-claude spawn) held until demonstrated under disposable id (CI-ACCEPTANCE-PLAN follow-up).

[[requirements]]
id = "REQ-HAZARD-PERCH-COLLISION"
title = "The acceptance harness spawns every nested claude SUT under a DISPOSABLE identity (SPT_AGENT_ID=sptc-ci-<n>), never a live agent's perch id — a colliding id tears down the live agent's perch + poll stream (name-keyed, last-establish-wins)"
required_stages = ["unit"]  # unit: tests/acceptance-harness.sh. Diagnosed 2026-06-15 (operator): nested claude loading the spt plugin established a perch under "perri" and displaced the live poll stream.

# traceable-reqs manifest for spt-claude-code — SEED.
#
# Authoritative requirement registry: every requirement and every KNOWN-HAZARDS
# invariant lives here as a REQ-* id (see docs/TRACEABILITY.md for the full contract).
#
# SEED YOUR REQs FROM SCOPE.md "Parity-trim" — the DROP / TRANSFORM / KEEP / ADD buckets are
# the parity reference. Mint a REQ-* per surface you port/transform/add as you START each
# milestone (NOT up front). The registry below is intentionally EMPTY (commented shapes only).
#
# ACQUIRE the CLI (it is a released binary, not a crate dependency):
#   gh release download --repo BigscreenVR/traceable-reqs \
#     --pattern '*<your-platform>' --output traceable-reqs   # then chmod +x
# (see INSTANTIATE.md for per-platform patterns + CI wiring.) Validate this seed against
# the installed CLI before relying on it: `traceable-reqs check`.
#
# THE FOUR-STAGE CONTRACT: every requirement is traced from
#   doc  (prose/design)  ->  impl (production code)  ->  unit (unit test)  ->  int (cross-process/node)
# Tag evidence in the SAME commit, on/above the real evidence:
#   // [impl->REQ-FOO]   // [unit->REQ-FOO]   <!-- [doc->REQ-FOO] -->
#
# ACTIVATION MODEL: every requirement starts inactive (`required_stages = []`) so `check`
# stays green pre-code. A milestone ACTIVATES its requirements by setting their real
# `required_stages` when work on them begins. Deferred items stay [] until promoted.
# Never declare the full end-state stage list up front — activate incrementally as evidence
# lands (add "int" at the final integrating wave).

[scan]
# Every evidence location. A missing root makes evidence SILENTLY vanish from the trace —
# audit this whenever a new evidence dir appears. ADD `plugin`, `skills`, `hooks`, `tests`
# the moment those dirs are created (skeleton milestone, body #2) — until they exist a
# nonexistent root is a hard manifest_error, so only list what is on disk now.
roots = ["plugin", "ci", "ci/acceptance", "tests", "docs", "docs-site", "CONTEXT.md", "SCOPE.md", "AGENTS.md", "HANDOFF.md"]

[policy]
# Default policy applied at activation: doc + impl + unit. Networking/lifecycle/cross-node
# reqs add "int". Until a req is activated, its per-req `required_stages = []` keeps it out
# of the coverage gate.
required_stages = []

# ───────────────────────────── Requirements ─────────────────────────────────
# Add requirements as you scope milestones, seeded from SCOPE.md "Parity-trim". Shape:
#
# [[requirements]]
# id = "REQ-EXAMPLE-1"
# title = "One-line statement of the requirement"
# required_stages = []  # activate (e.g. ["doc","impl","unit"]) when the milestone starts
#
# KNOWN-HAZARDS invariants are first-class requirements — one REQ-HAZARD-* per invariant,
# each requiring at least "unit" (add "int" where cross-process/cross-node):
#
# [[requirements]]
# id = "REQ-HAZARD-EXAMPLE"
# title = "The invariant, stated so a test can prove it"
# required_stages = []

# ── Milestone: SKELETON (thin cplugs plugin) — minted at kickoff, per-milestone (not
# the whole registry up front). All inactive (required_stages = []) so `check` stays
# green until body #2 lands evidence and flips each to ["doc"] / ["doc","impl"] etc.
# Seeded from SCOPE.md "Parity-trim" (Distribution + ADD buckets).

[[requirements]]
id = "REQ-DIST-PLUGIN-SKELETON"
title = "Marketplace artifact on cplugs is a thin skeleton: namespaced /sptc:* skill stubs + hooks.json + plugin.json, no embedded logic"
required_stages = ["doc"]  # doc: ADR-0001. impl held until hooks.json lands (gap-F-001 #2).

[[requirements]]
id = "REQ-DIST-BOOTSTRAP-INSTALL"
title = "SessionStart bootstrap installs spt-core when absent (invisible-installer pattern)"
required_stages = ["doc", "impl"]  # doc: ADR-0001. impl: plugin/sptc/bootstrap.{sh,ps1}.

[[requirements]]
id = "REQ-DIST-HOOKS-API"
title = "hooks.json delegates to `spt api` rather than carrying adapter logic"
required_stages = ["doc", "impl", "unit"]  # doc: ADR-0001/0002. impl: hooks.json + wrappers. unit: tests/hooks-parse.sh. int (real-CC e2e) held for throwaway validation.

[[requirements]]
id = "REQ-DIST-MANIFEST-SCHEMA"
title = "CC adapter manifest validates against spt-core's published manifest.schema.json (from spt-releases)"
required_stages = []

[[requirements]]
id = "REQ-UPS-INJECTION"
title = "UserPromptSubmit hook detects /sptc:X and injects X's real instructions as additionalContext (SKILL.md files stay skeletons); must be empirically confirmed UPS fires on slash-commands"
required_stages = ["doc", "impl", "unit"]  # doc: ADR-0002. impl: user-prompt-submit.sh. unit: render_frames test. int (UPS-fires + multi-frame post-F-002) held for throwaway validation.

# ── Milestone: CI (autonomous fleet) — minted on-start. Deterministic gates + git push-trigger +
# spt reporting bus. Seeded from SCOPE.md "CI model" + docs/CI.md. Real-claude acceptance is a
# later slice (doc-only now). All harness-agnostic shell; runs on the Win+Linux fleet.

[[requirements]]
id = "REQ-CI-GATES"
title = "Deterministic gates (shell-syntax + unit tests + traceable-reqs check + manifest-schema + docs-drift), each a binary pass/fail, runnable as one command"
required_stages = ["doc", "impl", "unit"]

[[requirements]]
id = "REQ-CI-MANUAL"
title = "A manual 'run gates' command always exists — the same gate scripts runnable by hand on any fleet host"
required_stages = ["doc", "impl"]

[[requirements]]
id = "REQ-CI-TRIGGER"
title = "A git push hook fires the gates: pings a fleet runner-agent over spt rather than polling"
required_stages = ["doc", "impl"]

[[requirements]]
id = "REQ-CI-BUS"
title = "CI progress/results report over spt messaging (dogfood the product as its own CI nervous system)"
required_stages = ["doc", "impl"]

[[requirements]]
id = "REQ-CI-OWL-DISCOVERY"
title = "The spt messaging (bus) binary is located robustly at run time (per-version plugins path / PATH / configured) — never a hard-coded versioned path"
required_stages = ["doc", "impl", "unit"]

[[requirements]]
id = "REQ-CI-ACCEPTANCE"
title = "Acceptance = scripted orchestration spawning real claude/headless sessions as the system-under-test, asserting spt-state/digest output (LLM is SUT, never the runner)"
required_stages = ["doc"]

[[requirements]]
id = "REQ-HAZARD-MSYS-PATHCONV"
title = "Hook wrappers read the CC payload from stdin, never from a /-leading positional argv (Git-Bash/MSYS path-mangles those on Windows)"
required_stages = ["unit"]  # unit: tests/msys-hazard.sh. Confirmed live 2026-06-15 (UPS validation run A).

[[requirements]]
id = "REQ-SKILL-VERSION"
title = "/spt:version reports the spt-core-tracked manifest/binary version (the version-of-truth, not the marketplace skeleton version)"
required_stages = []

+required_stages = ["doc", "impl"]  # doc: docs/CI.md. impl: ci/acceptance/{lib,run-acceptance}.sh + harness unit. int (real-claude spawn) held until demonstrated under disposable id (CI-ACCEPTANCE-PLAN follow-up).

+title = "The acceptance harness spawns every nested claude SUT under a DISPOSABLE identity (SPT_AGENT_ID=sptc-ci-<n>), never a live agent's perch id — a colliding id tears down the live agent's perch + poll stream (name-keyed, last-establish-wins)"

+required_stages = ["unit"]  # unit: tests/acceptance-harness.sh. Diagnosed 2026-06-15 (operator): nested claude loading the spt plugin established a perch under "perri" and displaced the live poll stream.


- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

---

## 2. Live-agent perch / CI orchestration

### 2.1 Nested-`claude` perch collision tears down the live agent's poll stream

<!-- [doc->REQ-HAZARD-PERCH-COLLISION] -->

- **Failure:** The CI acceptance harness spawns a real `claude` session as the system-under-test.
  If that nested session loads the spt plugin (whose SessionStart establishes a perch) and resolves
  the **same perch id as the live operator agent** (e.g. `perri`), the nested establish **displaces
  the operator's perch** — perches are name-keyed, last-establish-wins — killing the operator's
  `api poll` / live stream. Observed 2026-06-15 as the live poll Monitor exiting `exit-1` plus a
  `sessions log seal failed: git failed` on revive (the collision teardown). Diagnosed by the
  operator as self-inflicted, NOT a legacy-substrate bug.
- **Invariant:** Every nested SUT the acceptance harness spawns MUST run under a **disposable
  identity** distinct from any live agent — `SPT_AGENT_ID=sptc-ci-<n>` (and the matching
  `OWL_SESSION_ID`), never a live agent name. The harness MUST set this for every spawn; it MUST
  NOT inherit the operator's `SPT_AGENT_ID`/`OWL_SESSION_ID`. A test asserts the harness always
  overrides both to a `sptc-ci-` id and never emits a live id.
- **Mapping / notes:** `ci/acceptance/lib.sh` `sptc_ci_identity` mints the disposable id and
  exports it into the SUT env; `ci/acceptance/run-acceptance.sh` spawns `claude -p` only through
  that env. The deterministic guard lives in `tests/acceptance-harness.sh` (no real `claude`
  needed — it asserts the env the harness would hand a spawn). Identity is the documented
  name-keyed knob (`spt whoami` resolves from `$OWL_SESSION_ID`/`$SPT_AGENT_ID`); a separate data
  dir is not part of the public surface, so isolation rides on identity.
- **cite:** Operator diagnosis 2026-06-15 (perch collision, self-inflicted via nested `claude -p`
  loading the spt plugin). Reference only — binding evidence is the tagged test under
  `REQ-HAZARD-PERCH-COLLISION`.


<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>

Error: File has not been read yet. Read it first before writing to it.


<system-reminder>Warning: the file exists but is shorter than the provided offset (60). The file has 56 lines.</system-reminder>


44	  argument** through a Git-Bash layer is corrupted the same way. (Legacy `claude_skill_owl`
45	  documents the identical hazard in `new-alarm`.)
46	- **Invariant:** sptc adapter glue MUST NOT depend on receiving `/`-leading content as a
47	  Git-Bash positional argument. Message/prompt content is read from the **hook stdin JSON**
48	  (`prompt`, message bodies), never reconstructed from a `/`-prefixed argv; any helper that must
49	  take such an argument uses a stdin/`--message-file` transport or `MSYS_NO_PATHCONV=1`.
50	- **Mapping / notes:** the hook wrappers (`plugin/sptc/hooks/*.sh`) are immune by construction —
51	  they parse the CC hook payload from stdin (`json_str`), not from argv. The invariant is the
52	  *commitment* to keep it that way (and to apply it to any future `/sptc:*` arg-taking surface).
53	  A test asserts the stdin path is honored (no argv `/`-token dependency).
54	- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
55	  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.
56

  argument** through a Git-Bash layer is corrupted the same way. (Legacy `claude_skill_owl`
  documents the identical hazard in `new-alarm`.)
- **Invariant:** sptc adapter glue MUST NOT depend on receiving `/`-leading content as a
  Git-Bash positional argument. Message/prompt content is read from the **hook stdin JSON**
  (`prompt`, message bodies), never reconstructed from a `/`-prefixed argv; any helper that must
  take such an argument uses a stdin/`--message-file` transport or `MSYS_NO_PATHCONV=1`.
- **Mapping / notes:** the hook wrappers (`plugin/sptc/hooks/*.sh`) are immune by construction —
  they parse the CC hook payload from stdin (`json_str`), not from argv. The invariant is the
  *commitment* to keep it that way (and to apply it to any future `/sptc:*` arg-taking surface).
  A test asserts the stdin path is honored (no argv `/`-token dependency).
- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.


- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

---

## 2. Live-agent perch / CI orchestration

### 2.1 Nested-`claude` perch collision tears down the live agent's poll stream

<!-- [doc->REQ-HAZARD-PERCH-COLLISION] -->

- **Failure:** The CI acceptance harness spawns a real `claude` session as the system-under-test.
  If that nested session loads the spt plugin (whose SessionStart establishes a perch) and resolves
  the **same perch id as the live operator agent** (e.g. `perri`), the nested establish **displaces
  the operator's perch** — perches are name-keyed, last-establish-wins — killing the operator's
  `api poll` / live stream. Observed 2026-06-15 as the live poll Monitor exiting `exit-1` plus a
  `sessions log seal failed: git failed` on revive (the collision teardown). Diagnosed by the
  operator as self-inflicted, NOT a legacy-substrate bug.
- **Invariant:** Every nested SUT the acceptance harness spawns MUST run under a **disposable
  identity** distinct from any live agent — `SPT_AGENT_ID=sptc-ci-<n>` (and the matching
  `OWL_SESSION_ID`), never a live agent name. The harness MUST set this for every spawn; it MUST
  NOT inherit the operator's `SPT_AGENT_ID`/`OWL_SESSION_ID`. A test asserts the harness always
  overrides both to a `sptc-ci-` id and never emits a live id.
- **Mapping / notes:** `ci/acceptance/lib.sh` `sptc_ci_identity` mints the disposable id and
  exports it into the SUT env; `ci/acceptance/run-acceptance.sh` spawns `claude -p` only through
  that env. The deterministic guard lives in `tests/acceptance-harness.sh` (no real `claude`
  needed — it asserts the env the harness would hand a spawn). Identity is the documented
  name-keyed knob (`spt whoami` resolves from `$OWL_SESSION_ID`/`$SPT_AGENT_ID`); a separate data
  dir is not part of the public surface, so isolation rides on identity.
- **cite:** Operator diagnosis 2026-06-15 (perch collision, self-inflicted via nested `claude -p`
  loading the spt plugin). Reference only — binding evidence is the tagged test under
  `REQ-HAZARD-PERCH-COLLISION`.


The file C:\Users\decid\Documents\projects\spt-claude-code\docs\KNOWN-HAZARDS.md has been updated successfully. (file state is current in your context — no need to Read it back)

- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

---

## 2. Live-agent perch / CI orchestration

### 2.1 Nested-`claude` perch collision tears down the live agent's poll stream

<!-- [doc->REQ-HAZARD-PERCH-COLLISION] -->

- **Failure:** The CI acceptance harness spawns a real `claude` session as the system-under-test.
  If that nested session loads the spt plugin (whose SessionStart establishes a perch) and resolves
  the **same perch id as the live operator agent** (e.g. `perri`), the nested establish **displaces
  the operator's perch** — perches are name-keyed, last-establish-wins — killing the operator's
  `api poll` / live stream. Observed 2026-06-15 as the live poll Monitor exiting `exit-1` plus a
  `sessions log seal failed: git failed` on revive (the collision teardown). Diagnosed by the
  operator as self-inflicted, NOT a legacy-substrate bug.
- **Invariant:** Every nested SUT the acceptance harness spawns MUST run under a **disposable
  identity** distinct from any live agent — `SPT_AGENT_ID=sptc-ci-<n>` (and the matching
  `OWL_SESSION_ID`), never a live agent name. The harness MUST set this for every spawn; it MUST
  NOT inherit the operator's `SPT_AGENT_ID`/`OWL_SESSION_ID`. A test asserts the harness always
  overrides both to a `sptc-ci-` id and never emits a live id.
- **Mapping / notes:** `ci/acceptance/lib.sh` `sptc_ci_identity` mints the disposable id and
  exports it into the SUT env; `ci/acceptance/run-acceptance.sh` spawns `claude -p` only through
  that env. The deterministic guard lives in `tests/acceptance-harness.sh` (no real `claude`
  needed — it asserts the env the harness would hand a spawn). Identity is the documented
  name-keyed knob (`spt whoami` resolves from `$OWL_SESSION_ID`/`$SPT_AGENT_ID`); a separate data
  dir is not part of the public surface, so isolation rides on identity.
- **cite:** Operator diagnosis 2026-06-15 (perch collision, self-inflicted via nested `claude -p`
  loading the spt plugin). Reference only — binding evidence is the tagged test under
  `REQ-HAZARD-PERCH-COLLISION`.

# Known Hazards

> A **conformance checklist, not advice.** Each hazard below is a first-class
> `REQ-HAZARD-*` requirement in `traceable-reqs.toml`, and is **not "covered" until a test tags
> it** (`unit`, plus `int` where the failure is cross-process / cross-node). This file exists to
> make "we won't re-break X" mechanical: an entry without a passing tagged test is an open risk,
> and `traceable-reqs check` will say so once the hazard is activated.

A hazard earns a place here when it is an invariant you have *paid for once* (a real bug, an
incident) or one you have *committed never to introduce*. State it so a test can prove it.

## Entry format

Each entry is one numbered subsection with these fields:

- **Failure** — the concrete bad behavior: what goes wrong, under what sequence / timing / input.
- **Invariant** — the property that MUST hold, phrased so a test can assert it (the thing the
  `REQ-HAZARD-*` requires).
- **Mapping / notes** — where this lives in *this* project, and anything that changes the shape
  of the test (e.g. "in-process now, so use a lock instead of racing on disk").
- **cite** — where the failure / fix is evidenced (an incident, a prior commit, a source path);
  reference only — the binding evidence is the tagged test.

Mirror each entry as a requirement:

```toml
[[requirements]]
id = "REQ-HAZARD-EXAMPLE"
title = "The invariant, stated so a test can prove it"
required_stages = []   # activate (["unit"] or ["unit","int"]) when you cover it
```

---

## 1. Windows / MSYS shell environment

### 1.1 MSYS `/`-prefix path conversion mangles slash-leading arguments

- **Failure:** On Windows under Git-Bash / MSYS, any **command-line argument** beginning with `/`
  is silently rewritten to an absolute Windows path before the target binary sees it. Observed
  2026-06-15 during the UPS-fires validation: invoking `claude -p "/send hi"` from Git-Bash
  delivered the prompt to Claude Code as `C:/Program Files/Git/send hi` — the `/send` token was
  path-converted. Anything that passes a `/sptc:…` (or other `/`-leading) token as a **positional
  argument** through a Git-Bash layer is corrupted the same way. (Legacy `claude_skill_owl`
  documents the identical hazard in `new-alarm`.)
- **Invariant:** sptc adapter glue MUST NOT depend on receiving `/`-leading content as a
  Git-Bash positional argument. Message/prompt content is read from the **hook stdin JSON**
  (`prompt`, message bodies), never reconstructed from a `/`-prefixed argv; any helper that must
  take such an argument uses a stdin/`--message-file` transport or `MSYS_NO_PATHCONV=1`.
- **Mapping / notes:** the hook wrappers (`plugin/sptc/hooks/*.sh`) are immune by construction —
  they parse the CC hook payload from stdin (`json_str`), not from argv. The invariant is the
  *commitment* to keep it that way (and to apply it to any future `/sptc:*` arg-taking surface).
  A test asserts the stdin path is honored (no argv `/`-token dependency).
- **cite:** UPS-fires validation 2026-06-15 (`ups.log`, run A); legacy `new-alarm` SKILL.md
  MSYS note. Reference only — binding evidence is the tagged test under `REQ-HAZARD-MSYS-PATHCONV`.

+## 2. Live-agent perch / CI orchestration

+### 2.1 Nested-`claude` perch collision tears down the live agent's poll stream

+- **Failure:** The CI acceptance harness spawns a real `claude` session as the system-under-test.

+  If that nested session loads the spt plugin (whose SessionStart establishes a perch) and resolves

+  the **same perch id as the live operator agent** (e.g. `perri`), the nested establish **displaces

+  the operator's perch** — perches are name-keyed, last-establish-wins — killing the operator's

+  `api poll` / live stream. Observed 2026-06-15 as the live poll Monitor exiting `exit-1` plus a

+  `sessions log seal failed: git failed` on revive (the collision teardown). Diagnosed by the

+  operator as self-inflicted, NOT a legacy-substrate bug.

+- **Invariant:** Every nested SUT the acceptance harness spawns MUST run under a **disposable

+  identity** distinct from any live agent — `SPT_AGENT_ID=sptc-ci-<n>` (and the matching

+  `OWL_SESSION_ID`), never a live agent name. The harness MUST set this for every spawn; it MUST

+  NOT inherit the operator's `SPT_AGENT_ID`/`OWL_SESSION_ID`. A test asserts the harness always

+  overrides both to a `sptc-ci-` id and never emits a live id.

+- **Mapping / notes:** `ci/acceptance/lib.sh` `sptc_ci_identity` mints the disposable id and

+  exports it into the SUT env; `ci/acceptance/run-acceptance.sh` spawns `claude -p` only through

+  that env. The deterministic guard lives in `tests/acceptance-harness.sh` (no real `claude`

+  needed — it asserts the env the harness would hand a spawn). Identity is the documented

+  name-keyed knob (`spt whoami` resolves from `$OWL_SESSION_ID`/`$SPT_AGENT_ID`); a separate data

+  dir is not part of the public surface, so isolation rides on identity.

+- **cite:** Operator diagnosis 2026-06-15 (perch collision, self-inflicted via nested `claude -p`

+  loading the spt plugin). Reference only — binding evidence is the tagged test under


Now the harness lib. Disposable-identity + scaffold + assert helpers.


Write

# shellcheck shell=sh
# sptc CI acceptance harness — shared helpers. Pure/deterministic except sptc_ci_spawn (which
# drives a real `claude`). Sourced by run-acceptance.sh and exercised (sans real claude) by
# tests/acceptance-harness.sh. [impl->REQ-CI-ACCEPTANCE]

# ── Disposable identity (perch-collision guard) ──────────────────────────────
# Mint a throwaway perch id and EXPORT it over any inherited live-agent identity, so a nested
# SUT can never resolve the operator's perch id (name-keyed, last-establish-wins → teardown).
# $1 = a caller-supplied uniquifier (e.g. a counter or pid); never random (scripts stay replayable).
# Echoes the id; exports SPT_AGENT_ID + OWL_SESSION_ID so `spt whoami` inside the SUT resolves it.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_identity() {
  _n="${1:?sptc_ci_identity needs a uniquifier}"
  _id="sptc-ci-${_n}"
  # Hard override — must NOT inherit the operator's identity into the spawn env.
  SPT_AGENT_ID="$_id"
  OWL_SESSION_ID="$_id"
  export SPT_AGENT_ID OWL_SESSION_ID
  printf '%s' "$_id"
}

# True iff $1 is a disposable CI identity (the shape the harness is allowed to spawn under).
# Used by the guard test and as a belt-and-braces preflight before any real spawn.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_is_disposable() {
  case "${1:-}" in
    sptc-ci-?*) return 0 ;;
    *) return 1 ;;
  esac
}

# ── Isolated project scaffold ────────────────────────────────────────────────
# Build a throwaway CC project that wires a UserPromptSubmit hook writing a digest marker to
# $2 (the digest file). Echoes the project dir. The fixture hook is deliberately self-contained
# (no spt bus, no auth beyond claude's own) so slice-1 acceptance asserts purely that a real
# harness FIRES the hook entry point. $1 = project dir (created), $2 = digest file path.
sptc_ci_mkproject() {
  _proj="${1:?need project dir}"; _digest="${2:?need digest file}"
  mkdir -p "$_proj/.claude/hooks" || return 1
  # Hook reads CC's stdin payload (stdin, never argv — KH 1.1) and appends a marker.
  cat > "$_proj/.claude/hooks/ups-digest.sh" <<HOOK
#!/bin/sh
# Acceptance fixture: prove a real claude fires UserPromptSubmit. Reads stdin payload, never argv.
input=\$(cat)
prompt=\$(printf '%s' "\$input" | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
printf 'UPS_FIRED:%s\n' "\$prompt" >> "$_digest"
exit 0
HOOK
  chmod +x "$_proj/.claude/hooks/ups-digest.sh" 2>/dev/null || true
  cat > "$_proj/.claude/settings.json" <<SETTINGS
{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$_proj/.claude/hooks/ups-digest.sh\"" } ] }
    ]
  }
}
SETTINGS
  printf '%s' "$_proj"
}

# ── Assertion ────────────────────────────────────────────────────────────────
# sptc_ci_assert <label> <needle> <file> : pass iff <file> contains <needle>. Deterministic
# side-effect / digest assertion — the harness never judges model text. Returns 0/1, prints PASS/FAIL.
sptc_ci_assert() {
  _label="$1"; _needle="$2"; _file="$3"
  if [ -f "$_file" ] && grep -q "$_needle" "$_file" 2>/dev/null; then
    printf 'PASS: %s\n' "$_label"; return 0
  fi
  printf 'FAIL: %s (no "%s" in %s)\n' "$_label" "$_needle" "$_file"; return 1
}


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\ci\acceptance\lib.sh (file state is current in your context — no need to Read it back)

# shellcheck shell=sh
# sptc CI acceptance harness — shared helpers. Pure/deterministic except sptc_ci_spawn (which
# drives a real `claude`). Sourced by run-acceptance.sh and exercised (sans real claude) by
# tests/acceptance-harness.sh. [impl->REQ-CI-ACCEPTANCE]

# ── Disposable identity (perch-collision guard) ──────────────────────────────
# Mint a throwaway perch id and EXPORT it over any inherited live-agent identity, so a nested
# SUT can never resolve the operator's perch id (name-keyed, last-establish-wins → teardown).
# $1 = a caller-supplied uniquifier (e.g. a counter or pid); never random (scripts stay replayable).
# Echoes the id; exports SPT_AGENT_ID + OWL_SESSION_ID so `spt whoami` inside the SUT resolves it.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_identity() {
  _n="${1:?sptc_ci_identity needs a uniquifier}"
  _id="sptc-ci-${_n}"
  # Hard override — must NOT inherit the operator's identity into the spawn env.
  SPT_AGENT_ID="$_id"
  OWL_SESSION_ID="$_id"
  export SPT_AGENT_ID OWL_SESSION_ID
  printf '%s' "$_id"
}

# True iff $1 is a disposable CI identity (the shape the harness is allowed to spawn under).
# Used by the guard test and as a belt-and-braces preflight before any real spawn.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_is_disposable() {
  case "${1:-}" in
    sptc-ci-?*) return 0 ;;
    *) return 1 ;;
  esac
}

# ── Isolated project scaffold ────────────────────────────────────────────────
# Build a throwaway CC project that wires a UserPromptSubmit hook writing a digest marker to
# $2 (the digest file). Echoes the project dir. The fixture hook is deliberately self-contained
# (no spt bus, no auth beyond claude's own) so slice-1 acceptance asserts purely that a real
# harness FIRES the hook entry point. $1 = project dir (created), $2 = digest file path.
sptc_ci_mkproject() {
  _proj="${1:?need project dir}"; _digest="${2:?need digest file}"
  mkdir -p "$_proj/.claude/hooks" || return 1
  # Hook reads CC's stdin payload (stdin, never argv — KH 1.1) and appends a marker.
  cat > "$_proj/.claude/hooks/ups-digest.sh" <<HOOK
#!/bin/sh
# Acceptance fixture: prove a real claude fires UserPromptSubmit. Reads stdin payload, never argv.
input=\$(cat)
prompt=\$(printf '%s' "\$input" | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
printf 'UPS_FIRED:%s\n' "\$prompt" >> "$_digest"
exit 0
HOOK
  chmod +x "$_proj/.claude/hooks/ups-digest.sh" 2>/dev/null || true
  cat > "$_proj/.claude/settings.json" <<SETTINGS
{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$_proj/.claude/hooks/ups-digest.sh\"" } ] }
    ]
  }
}
SETTINGS
  printf '%s' "$_proj"
}

# ── Assertion ────────────────────────────────────────────────────────────────
# sptc_ci_assert <label> <needle> <file> : pass iff <file> contains <needle>. Deterministic
# side-effect / digest assertion — the harness never judges model text. Returns 0/1, prints PASS/FAIL.
sptc_ci_assert() {
  _label="$1"; _needle="$2"; _file="$3"
  if [ -f "$_file" ] && grep -q "$_needle" "$_file" 2>/dev/null; then
    printf 'PASS: %s\n' "$_label"; return 0
  fi
  printf 'FAIL: %s (no "%s" in %s)\n' "$_label" "$_needle" "$_file"; return 1
}


Now the orchestrator. Env-gated, spawns real `claude -p` under disposable id.


#!/bin/sh
# sptc CI ACCEPTANCE — slice 1: spawn a REAL `claude` as the system-under-test, assert it fired
# the UserPromptSubmit hook (a deterministic digest-marker side-effect). The LLM is the SUT; this
# orchestration is deterministic and never judges model text. [impl->REQ-CI-ACCEPTANCE]
#
# SLOW LANE — env-gated. Skips cleanly (rc 0) unless SPTC_ACCEPTANCE=1 AND `claude` is on PATH, so
# the deterministic gate run stays green on hosts without claude/auth. Real-claude execution is the
# cross-process evidence for REQ-CI-ACCEPTANCE: [int->REQ-CI-ACCEPTANCE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
. "$ROOT/ci/acceptance/lib.sh"

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then
  echo "SKIP acceptance: set SPTC_ACCEPTANCE=1 to run the real-claude lane (deterministic gates unaffected)"
  exit 0
fi
if ! command -v claude >/dev/null 2>&1; then
  echo "SKIP acceptance: no \`claude\` on PATH (fleet host without the harness)"
  exit 0
fi

# Disposable identity FIRST — before any scaffold/spawn — so nothing can resolve the operator perch.
# [int->REQ-HAZARD-PERCH-COLLISION]
id=$(sptc_ci_identity "$$")
sptc_ci_is_disposable "$id" || { echo "FATAL: refusing to spawn under non-disposable id '$id'"; exit 2; }
echo "acceptance: SUT identity = $id (operator perch protected)"

work=$(mktemp -d 2>/dev/null) || { echo "FATAL: mktemp -d failed"; exit 2; }
proj="$work/proj"
digest="$work/digest.txt"
: > "$digest"
cleanup() { rm -rf "$work" 2>/dev/null || true; }
trap cleanup EXIT INT TERM

sptc_ci_mkproject "$proj" "$digest" >/dev/null || { echo "FATAL: scaffold failed"; exit 2; }

probe="sptc-acceptance-probe-$$"
echo "acceptance: driving real claude -p in $proj (probe='$probe')"
# Drive the SUT. We assert on the hook side-effect, not stdout — model text is irrelevant.
( cd "$proj" && claude -p "$probe" >/dev/null 2>&1 ) || \
  echo "note: claude -p exited non-zero (model/auth) — asserting on hook side-effect regardless" >&2

rc=0
sptc_ci_assert "UPS hook fired in real claude" "UPS_FIRED:$probe" "$digest" || rc=1

printf '\n=== ACCEPTANCE: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\ci\acceptance\run-acceptance.sh (file state is current in your context — no need to Read it back)

#!/bin/sh
# sptc CI ACCEPTANCE — slice 1: spawn a REAL `claude` as the system-under-test, assert it fired
# the UserPromptSubmit hook (a deterministic digest-marker side-effect). The LLM is the SUT; this
# orchestration is deterministic and never judges model text. [impl->REQ-CI-ACCEPTANCE]
#
# SLOW LANE — env-gated. Skips cleanly (rc 0) unless SPTC_ACCEPTANCE=1 AND `claude` is on PATH, so
# the deterministic gate run stays green on hosts without claude/auth. Real-claude execution is the
# cross-process evidence for REQ-CI-ACCEPTANCE: [int->REQ-CI-ACCEPTANCE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
. "$ROOT/ci/acceptance/lib.sh"

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then
  echo "SKIP acceptance: set SPTC_ACCEPTANCE=1 to run the real-claude lane (deterministic gates unaffected)"
  exit 0
fi
if ! command -v claude >/dev/null 2>&1; then
  echo "SKIP acceptance: no \`claude\` on PATH (fleet host without the harness)"
  exit 0
fi

# Disposable identity FIRST — before any scaffold/spawn — so nothing can resolve the operator perch.
# [int->REQ-HAZARD-PERCH-COLLISION]
id=$(sptc_ci_identity "$$")
sptc_ci_is_disposable "$id" || { echo "FATAL: refusing to spawn under non-disposable id '$id'"; exit 2; }
echo "acceptance: SUT identity = $id (operator perch protected)"

work=$(mktemp -d 2>/dev/null) || { echo "FATAL: mktemp -d failed"; exit 2; }
proj="$work/proj"
digest="$work/digest.txt"
: > "$digest"
cleanup() { rm -rf "$work" 2>/dev/null || true; }
trap cleanup EXIT INT TERM

sptc_ci_mkproject "$proj" "$digest" >/dev/null || { echo "FATAL: scaffold failed"; exit 2; }

probe="sptc-acceptance-probe-$$"
echo "acceptance: driving real claude -p in $proj (probe='$probe')"
# Drive the SUT. We assert on the hook side-effect, not stdout — model text is irrelevant.
( cd "$proj" && claude -p "$probe" >/dev/null 2>&1 ) || \
  echo "note: claude -p exited non-zero (model/auth) — asserting on hook side-effect regardless" >&2

rc=0
sptc_ci_assert "UPS hook fired in real claude" "UPS_FIRED:$probe" "$digest" || rc=1

printf '\n=== ACCEPTANCE: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"


Now the harness unit test — perch-collision guard + scaffold + assert + env-gate.


#!/bin/sh
# Unit tests for the CI acceptance harness — deterministic pieces only, NO real `claude` spawned.
# Run: sh tests/acceptance-harness.sh   (exit 0 = pass).
. "$(dirname "$0")/../ci/acceptance/lib.sh"
fail=0

# ── Perch-collision guard: identity is ALWAYS overridden to a disposable id, never a live name.
# [unit->REQ-HAZARD-PERCH-COLLISION]
# Simulate inheriting the operator's identity, then mint — both vars must be displaced.
SPT_AGENT_ID=perri OWL_SESSION_ID=perri
got=$(sptc_ci_identity 7)
[ "$got" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$got]"; fail=1; }
[ "$SPT_AGENT_ID" = "sptc-ci-7" ] && echo "ok   SPT_AGENT_ID overridden" || { echo "FAIL SPT_AGENT_ID=[$SPT_AGENT_ID] not overridden"; fail=1; }
[ "$OWL_SESSION_ID" = "sptc-ci-7" ] && echo "ok   OWL_SESSION_ID overridden" || { echo "FAIL OWL_SESSION_ID=[$OWL_SESSION_ID] not overridden"; fail=1; }
[ "$SPT_AGENT_ID" != "perri" ] && echo "ok   live id displaced" || { echo "FAIL still resolves live id"; fail=1; }

# is_disposable: accepts a minted id, rejects any live name.
sptc_ci_is_disposable "sptc-ci-7" && echo "ok   accepts disposable" || { echo "FAIL rejected disposable"; fail=1; }
sptc_ci_is_disposable "perri" && { echo "FAIL accepted live id 'perri'"; fail=1; } || echo "ok   rejects live id"
sptc_ci_is_disposable "" && { echo "FAIL accepted empty id"; fail=1; } || echo "ok   rejects empty id"

# ── Scaffold: builds settings.json + a stdin-reading (never argv) UPS hook fixture.
work=$(mktemp -d) || { echo "FAIL mktemp"; exit 1; }
trap 'rm -rf "$work"' EXIT INT TERM
proj="$work/p"; digest="$work/d.txt"
sptc_ci_mkproject "$proj" "$digest" >/dev/null
[ -f "$proj/.claude/settings.json" ] && echo "ok   scaffold writes settings.json" || { echo "FAIL no settings.json"; fail=1; }
[ -f "$proj/.claude/hooks/ups-digest.sh" ] && echo "ok   scaffold writes UPS hook" || { echo "FAIL no UPS hook"; fail=1; }
grep -q 'UserPromptSubmit' "$proj/.claude/settings.json" && echo "ok   settings wires UserPromptSubmit" || { echo "FAIL UPS not wired"; fail=1; }
# Fixture hook reads stdin, not a /-leading argv (KH 1.1 immunity).
grep -q 'input=$(cat)' "$proj/.claude/hooks/ups-digest.sh" && echo "ok   fixture reads stdin (MSYS-safe)" || { echo "FAIL fixture not stdin-driven"; fail=1; }

# Fixture actually produces the digest marker when fed a real CC-shaped payload on stdin.
printf '%s' '{"session_id":"x","prompt":"hello-probe"}' | sh "$proj/.claude/hooks/ups-digest.sh"
sptc_ci_assert "fixture emits digest marker" "UPS_FIRED:hello-probe" "$digest" >/dev/null \
  && echo "ok   fixture emits digest marker" || { echo "FAIL fixture no marker"; fail=1; }

# ── Assert helper: negative case (missing needle) must FAIL, not false-pass.
if sptc_ci_assert "neg" "NOPE" "$digest" >/dev/null; then echo "FAIL assert false-passed"; fail=1; else echo "ok   assert fails on missing needle"; fi

# ── Orchestrator is env-gated: without SPTC_ACCEPTANCE it skips cleanly (rc 0), spawns no claude.
out=$(SPTC_ACCEPTANCE=0 sh "$(dirname "$0")/../ci/acceptance/run-acceptance.sh" 2>&1); orc=$?
{ [ "$orc" -eq 0 ] && printf '%s' "$out" | grep -q 'SKIP acceptance'; } \
  && echo "ok   orchestrator env-gate skips clean" || { echo "FAIL env-gate: rc=$orc out=[$out]"; fail=1; }

[ "$fail" -eq 0 ] && { echo "ACCEPTANCE-HARNESS OK"; exit 0; } || { echo "ACCEPTANCE-HARNESS FAIL"; exit 1; }


File created successfully at: C:\Users\decid\Documents\projects\spt-claude-code\tests\acceptance-harness.sh (file state is current in your context — no need to Read it back)

#!/bin/sh
# Unit tests for the CI acceptance harness — deterministic pieces only, NO real `claude` spawned.
# Run: sh tests/acceptance-harness.sh   (exit 0 = pass).
. "$(dirname "$0")/../ci/acceptance/lib.sh"
fail=0

# ── Perch-collision guard: identity is ALWAYS overridden to a disposable id, never a live name.
# [unit->REQ-HAZARD-PERCH-COLLISION]
# Simulate inheriting the operator's identity, then mint — both vars must be displaced.
SPT_AGENT_ID=perri OWL_SESSION_ID=perri
got=$(sptc_ci_identity 7)
[ "$got" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$got]"; fail=1; }
[ "$SPT_AGENT_ID" = "sptc-ci-7" ] && echo "ok   SPT_AGENT_ID overridden" || { echo "FAIL SPT_AGENT_ID=[$SPT_AGENT_ID] not overridden"; fail=1; }
[ "$OWL_SESSION_ID" = "sptc-ci-7" ] && echo "ok   OWL_SESSION_ID overridden" || { echo "FAIL OWL_SESSION_ID=[$OWL_SESSION_ID] not overridden"; fail=1; }
[ "$SPT_AGENT_ID" != "perri" ] && echo "ok   live id displaced" || { echo "FAIL still resolves live id"; fail=1; }

# is_disposable: accepts a minted id, rejects any live name.
sptc_ci_is_disposable "sptc-ci-7" && echo "ok   accepts disposable" || { echo "FAIL rejected disposable"; fail=1; }
sptc_ci_is_disposable "perri" && { echo "FAIL accepted live id 'perri'"; fail=1; } || echo "ok   rejects live id"
sptc_ci_is_disposable "" && { echo "FAIL accepted empty id"; fail=1; } || echo "ok   rejects empty id"

# ── Scaffold: builds settings.json + a stdin-reading (never argv) UPS hook fixture.
work=$(mktemp -d) || { echo "FAIL mktemp"; exit 1; }
trap 'rm -rf "$work"' EXIT INT TERM
proj="$work/p"; digest="$work/d.txt"
sptc_ci_mkproject "$proj" "$digest" >/dev/null
[ -f "$proj/.claude/settings.json" ] && echo "ok   scaffold writes settings.json" || { echo "FAIL no settings.json"; fail=1; }
[ -f "$proj/.claude/hooks/ups-digest.sh" ] && echo "ok   scaffold writes UPS hook" || { echo "FAIL no UPS hook"; fail=1; }
grep -q 'UserPromptSubmit' "$proj/.claude/settings.json" && echo "ok   settings wires UserPromptSubmit" || { echo "FAIL UPS not wired"; fail=1; }
# Fixture hook reads stdin, not a /-leading argv (KH 1.1 immunity).
grep -q 'input=$(cat)' "$proj/.claude/hooks/ups-digest.sh" && echo "ok   fixture reads stdin (MSYS-safe)" || { echo "FAIL fixture not stdin-driven"; fail=1; }

# Fixture actually produces the digest marker when fed a real CC-shaped payload on stdin.
printf '%s' '{"session_id":"x","prompt":"hello-probe"}' | sh "$proj/.claude/hooks/ups-digest.sh"
sptc_ci_assert "fixture emits digest marker" "UPS_FIRED:hello-probe" "$digest" >/dev/null \
  && echo "ok   fixture emits digest marker" || { echo "FAIL fixture no marker"; fail=1; }

# ── Assert helper: negative case (missing needle) must FAIL, not false-pass.
if sptc_ci_assert "neg" "NOPE" "$digest" >/dev/null; then echo "FAIL assert false-passed"; fail=1; else echo "ok   assert fails on missing needle"; fi

# ── Orchestrator is env-gated: without SPTC_ACCEPTANCE it skips cleanly (rc 0), spawns no claude.
out=$(SPTC_ACCEPTANCE=0 sh "$(dirname "$0")/../ci/acceptance/run-acceptance.sh" 2>&1); orc=$?
{ [ "$orc" -eq 0 ] && printf '%s' "$out" | grep -q 'SKIP acceptance'; } \
  && echo "ok   orchestrator env-gate skips clean" || { echo "FAIL env-gate: rc=$orc out=[$out]"; fail=1; }

[ "$fail" -eq 0 ] && { echo "ACCEPTANCE-HARNESS OK"; exit 0; } || { echo "ACCEPTANCE-HARNESS FAIL"; exit 1; }


Bash

cd "$ROOT" 2>/dev/null; sh tests/acceptance-harness.sh; echo "rc=$?"

Run acceptance-harness unit test


ok   mints disposable id
FAIL SPT_AGENT_ID=[perri] not overridden
FAIL OWL_SESSION_ID=[perri] not overridden
FAIL still resolves live id
ok   accepts disposable
ok   rejects live id
ok   rejects empty id
ok   scaffold writes settings.json
ok   scaffold writes UPS hook
ok   settings wires UserPromptSubmit
ok   fixture reads stdin (MSYS-safe)
ok   fixture emits digest marker
ok   assert fails on missing needle
ok   orchestrator env-gate skips clean
ACCEPTANCE-HARNESS FAIL
rc=1


Test caught a real bug: `id=$(sptc_ci_identity …)` runs the export in a subshell — env never reaches the parent that spawns `claude`. Real orchestrator bug too. Fix: set id into an exported var, don't capture via `$()`.


Edit

# $1 = a caller-supplied uniquifier (e.g. a counter or pid); never random (scripts stay replayable).
# Echoes the id; exports SPT_AGENT_ID + OWL_SESSION_ID so `spt whoami` inside the SUT resolves it.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_identity() {
  _n="${1:?sptc_ci_identity needs a uniquifier}"
  _id="sptc-ci-${_n}"
  # Hard override — must NOT inherit the operator's identity into the spawn env.
  SPT_AGENT_ID="$_id"
  OWL_SESSION_ID="$_id"
  export SPT_AGENT_ID OWL_SESSION_ID
  printf '%s' "$_id"
}

# $1 = a caller-supplied uniquifier (e.g. a counter or pid); never random (scripts stay replayable).
# Sets+exports SPT_AGENT_ID, OWL_SESSION_ID, and SPTC_CI_ID (the id) IN THE CURRENT SHELL so the
# mutation reaches the later spawn. Call it bare — do NOT capture with $(...), which would run the
# export in a subshell and lose it; read the result from $SPTC_CI_ID. [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_identity() {
  _n="${1:?sptc_ci_identity needs a uniquifier}"
  SPTC_CI_ID="sptc-ci-${_n}"
  # Hard override — must NOT inherit the operator's identity into the spawn env.
  SPT_AGENT_ID="$SPTC_CI_ID"
  OWL_SESSION_ID="$SPTC_CI_ID"
  export SPTC_CI_ID SPT_AGENT_ID OWL_SESSION_ID
}


The file C:\Users\decid\Documents\projects\spt-claude-code\ci\acceptance\lib.sh has been updated successfully. (file state is current in your context — no need to Read it back)

# $1 = a caller-supplied uniquifier (e.g. a counter or pid); never random (scripts stay replayable).
# Sets+exports SPT_AGENT_ID, OWL_SESSION_ID, and SPTC_CI_ID (the id) IN THE CURRENT SHELL so the
# mutation reaches the later spawn. Call it bare — do NOT capture with $(...), which would run the
# export in a subshell and lose it; read the result from $SPTC_CI_ID. [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_identity() {
  _n="${1:?sptc_ci_identity needs a uniquifier}"
  SPTC_CI_ID="sptc-ci-${_n}"
  # Hard override — must NOT inherit the operator's identity into the spawn env.
  SPT_AGENT_ID="$SPTC_CI_ID"
  OWL_SESSION_ID="$SPTC_CI_ID"
  export SPTC_CI_ID SPT_AGENT_ID OWL_SESSION_ID
}

# shellcheck shell=sh
# sptc CI acceptance harness — shared helpers. Pure/deterministic except sptc_ci_spawn (which
# drives a real `claude`). Sourced by run-acceptance.sh and exercised (sans real claude) by
# tests/acceptance-harness.sh. [impl->REQ-CI-ACCEPTANCE]

# ── Disposable identity (perch-collision guard) ──────────────────────────────
# Mint a throwaway perch id and EXPORT it over any inherited live-agent identity, so a nested
# SUT can never resolve the operator's perch id (name-keyed, last-establish-wins → teardown).
# $1 = a caller-supplied uniquifier (e.g. a counter or pid); never random (scripts stay replayable).
# Echoes the id; exports SPT_AGENT_ID + OWL_SESSION_ID so `spt whoami` inside the SUT resolves it.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_identity() {
  _n="${1:?sptc_ci_identity needs a uniquifier}"
  _id="sptc-ci-${_n}"
  # Hard override — must NOT inherit the operator's identity into the spawn env.
  SPT_AGENT_ID="$_id"
  OWL_SESSION_ID="$_id"
  export SPT_AGENT_ID OWL_SESSION_ID
  printf '%s' "$_id"
}

# True iff $1 is a disposable CI identity (the shape the harness is allowed to spawn under).
# Used by the guard test and as a belt-and-braces preflight before any real spawn.
# [impl->REQ-HAZARD-PERCH-COLLISION]
sptc_ci_is_disposable() {
  case "${1:-}" in
    sptc-ci-?*) return 0 ;;
    *) return 1 ;;
  esac
}

# ── Isolated project scaffold ────────────────────────────────────────────────
# Build a throwaway CC project that wires a UserPromptSubmit hook writing a digest marker to
# $2 (the digest file). Echoes the project dir. The fixture hook is deliberately self-contained
# (no spt bus, no auth beyond claude's own) so slice-1 acceptance asserts purely that a real
# harness FIRES the hook entry point. $1 = project dir (created), $2 = digest file path.
sptc_ci_mkproject() {
  _proj="${1:?need project dir}"; _digest="${2:?need digest file}"
  mkdir -p "$_proj/.claude/hooks" || return 1
  # Hook reads CC's stdin payload (stdin, never argv — KH 1.1) and appends a marker.
  cat > "$_proj/.claude/hooks/ups-digest.sh" <<HOOK
#!/bin/sh
# Acceptance fixture: prove a real claude fires UserPromptSubmit. Reads stdin payload, never argv.
input=\$(cat)
prompt=\$(printf '%s' "\$input" | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
printf 'UPS_FIRED:%s\n' "\$prompt" >> "$_digest"
exit 0
HOOK
  chmod +x "$_proj/.claude/hooks/ups-digest.sh" 2>/dev/null || true
  cat > "$_proj/.claude/settings.json" <<SETTINGS
{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [ { "type": "command", "command": "sh \"$_proj/.claude/hooks/ups-digest.sh\"" } ] }
    ]
  }
}
SETTINGS
  printf '%s' "$_proj"
}

# ── Assertion ────────────────────────────────────────────────────────────────
# sptc_ci_assert <label> <needle> <file> : pass iff <file> contains <needle>. Deterministic
# side-effect / digest assertion — the harness never judges model text. Returns 0/1, prints PASS/FAIL.
sptc_ci_assert() {
  _label="$1"; _needle="$2"; _file="$3"
  if [ -f "$_file" ] && grep -q "$_needle" "$_file" 2>/dev/null; then
    printf 'PASS: %s\n' "$_label"; return 0
  fi
  printf 'FAIL: %s (no "%s" in %s)\n' "$_label" "$_needle" "$_file"; return 1
}

-# Echoes the id; exports SPT_AGENT_ID + OWL_SESSION_ID so `spt whoami` inside the SUT resolves it.

+# Sets+exports SPT_AGENT_ID, OWL_SESSION_ID, and SPTC_CI_ID (the id) IN THE CURRENT SHELL so the

+# mutation reaches the later spawn. Call it bare — do NOT capture with $(...), which would run the

+# export in a subshell and lose it; read the result from $SPTC_CI_ID. [impl->REQ-HAZARD-PERCH-COLLISION]

+  export SPTC_CI_ID SPT_AGENT_ID OWL_SESSION_ID


id=$(sptc_ci_identity "$$")
sptc_ci_is_disposable "$id" || { echo "FATAL: refusing to spawn under non-disposable id '$id'"; exit 2; }
echo "acceptance: SUT identity = $id (operator perch protected)"

sptc_ci_identity "$$"          # sets+exports SPTC_CI_ID / SPT_AGENT_ID / OWL_SESSION_ID in-shell
sptc_ci_is_disposable "$SPTC_CI_ID" || { echo "FATAL: refusing to spawn under non-disposable id '$SPTC_CI_ID'"; exit 2; }
echo "acceptance: SUT identity = $SPTC_CI_ID (operator perch protected)"


The file C:\Users\decid\Documents\projects\spt-claude-code\ci\acceptance\run-acceptance.sh has been updated successfully. (file state is current in your context — no need to Read it back)

sptc_ci_identity "$$"          # sets+exports SPTC_CI_ID / SPT_AGENT_ID / OWL_SESSION_ID in-shell
sptc_ci_is_disposable "$SPTC_CI_ID" || { echo "FATAL: refusing to spawn under non-disposable id '$SPTC_CI_ID'"; exit 2; }
echo "acceptance: SUT identity = $SPTC_CI_ID (operator perch protected)"

#!/bin/sh
# sptc CI ACCEPTANCE — slice 1: spawn a REAL `claude` as the system-under-test, assert it fired
# the UserPromptSubmit hook (a deterministic digest-marker side-effect). The LLM is the SUT; this
# orchestration is deterministic and never judges model text. [impl->REQ-CI-ACCEPTANCE]
#
# SLOW LANE — env-gated. Skips cleanly (rc 0) unless SPTC_ACCEPTANCE=1 AND `claude` is on PATH, so
# the deterministic gate run stays green on hosts without claude/auth. Real-claude execution is the
# cross-process evidence for REQ-CI-ACCEPTANCE: [int->REQ-CI-ACCEPTANCE]
set -u
ROOT=$(CDPATH= cd "$(dirname "$0")/../.." && pwd)
. "$ROOT/ci/acceptance/lib.sh"

if [ "${SPTC_ACCEPTANCE:-0}" != "1" ]; then
  echo "SKIP acceptance: set SPTC_ACCEPTANCE=1 to run the real-claude lane (deterministic gates unaffected)"
  exit 0
fi
if ! command -v claude >/dev/null 2>&1; then
  echo "SKIP acceptance: no \`claude\` on PATH (fleet host without the harness)"
  exit 0
fi

# Disposable identity FIRST — before any scaffold/spawn — so nothing can resolve the operator perch.
# [int->REQ-HAZARD-PERCH-COLLISION]
id=$(sptc_ci_identity "$$")
sptc_ci_is_disposable "$id" || { echo "FATAL: refusing to spawn under non-disposable id '$id'"; exit 2; }
echo "acceptance: SUT identity = $id (operator perch protected)"

work=$(mktemp -d 2>/dev/null) || { echo "FATAL: mktemp -d failed"; exit 2; }
proj="$work/proj"
digest="$work/digest.txt"
: > "$digest"
cleanup() { rm -rf "$work" 2>/dev/null || true; }
trap cleanup EXIT INT TERM

sptc_ci_mkproject "$proj" "$digest" >/dev/null || { echo "FATAL: scaffold failed"; exit 2; }

probe="sptc-acceptance-probe-$$"
echo "acceptance: driving real claude -p in $proj (probe='$probe')"
# Drive the SUT. We assert on the hook side-effect, not stdout — model text is irrelevant.
( cd "$proj" && claude -p "$probe" >/dev/null 2>&1 ) || \
  echo "note: claude -p exited non-zero (model/auth) — asserting on hook side-effect regardless" >&2

rc=0
sptc_ci_assert "UPS hook fired in real claude" "UPS_FIRED:$probe" "$digest" || rc=1

printf '\n=== ACCEPTANCE: %s ===\n' "$([ "$rc" -eq 0 ] && echo PASS || echo FAIL)"
exit "$rc"

-sptc_ci_is_disposable "$id" || { echo "FATAL: refusing to spawn under non-disposable id '$id'"; exit 2; }

-echo "acceptance: SUT identity = $id (operator perch protected)"

+sptc_ci_identity "$$"          # sets+exports SPTC_CI_ID / SPT_AGENT_ID / OWL_SESSION_ID in-shell

+sptc_ci_is_disposable "$SPTC_CI_ID" || { echo "FATAL: refusing to spawn under non-disposable id '$SPTC_CI_ID'"; exit 2; }

+echo "acceptance: SUT identity = $SPTC_CI_ID (operator perch protected)"


SPT_AGENT_ID=perri OWL_SESSION_ID=perri
got=$(sptc_ci_identity 7)
[ "$got" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$got]"; fail=1; }

SPT_AGENT_ID=perri OWL_SESSION_ID=perri
sptc_ci_identity 7   # bare call (no $() — must mutate THIS shell)
[ "$SPTC_CI_ID" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$SPTC_CI_ID]"; fail=1; }


The file C:\Users\decid\Documents\projects\spt-claude-code\tests\acceptance-harness.sh has been updated successfully. (file state is current in your context — no need to Read it back)

SPT_AGENT_ID=perri OWL_SESSION_ID=perri
sptc_ci_identity 7   # bare call (no $() — must mutate THIS shell)
[ "$SPTC_CI_ID" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$SPTC_CI_ID]"; fail=1; }

#!/bin/sh
# Unit tests for the CI acceptance harness — deterministic pieces only, NO real `claude` spawned.
# Run: sh tests/acceptance-harness.sh   (exit 0 = pass).
. "$(dirname "$0")/../ci/acceptance/lib.sh"
fail=0

# ── Perch-collision guard: identity is ALWAYS overridden to a disposable id, never a live name.
# [unit->REQ-HAZARD-PERCH-COLLISION]
# Simulate inheriting the operator's identity, then mint — both vars must be displaced.
SPT_AGENT_ID=perri OWL_SESSION_ID=perri
got=$(sptc_ci_identity 7)
[ "$got" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$got]"; fail=1; }
[ "$SPT_AGENT_ID" = "sptc-ci-7" ] && echo "ok   SPT_AGENT_ID overridden" || { echo "FAIL SPT_AGENT_ID=[$SPT_AGENT_ID] not overridden"; fail=1; }
[ "$OWL_SESSION_ID" = "sptc-ci-7" ] && echo "ok   OWL_SESSION_ID overridden" || { echo "FAIL OWL_SESSION_ID=[$OWL_SESSION_ID] not overridden"; fail=1; }
[ "$SPT_AGENT_ID" != "perri" ] && echo "ok   live id displaced" || { echo "FAIL still resolves live id"; fail=1; }

# is_disposable: accepts a minted id, rejects any live name.
sptc_ci_is_disposable "sptc-ci-7" && echo "ok   accepts disposable" || { echo "FAIL rejected disposable"; fail=1; }
sptc_ci_is_disposable "perri" && { echo "FAIL accepted live id 'perri'"; fail=1; } || echo "ok   rejects live id"
sptc_ci_is_disposable "" && { echo "FAIL accepted empty id"; fail=1; } || echo "ok   rejects empty id"

# ── Scaffold: builds settings.json + a stdin-reading (never argv) UPS hook fixture.
work=$(mktemp -d) || { echo "FAIL mktemp"; exit 1; }
trap 'rm -rf "$work"' EXIT INT TERM
proj="$work/p"; digest="$work/d.txt"
sptc_ci_mkproject "$proj" "$digest" >/dev/null
[ -f "$proj/.claude/settings.json" ] && echo "ok   scaffold writes settings.json" || { echo "FAIL no settings.json"; fail=1; }
[ -f "$proj/.claude/hooks/ups-digest.sh" ] && echo "ok   scaffold writes UPS hook" || { echo "FAIL no UPS hook"; fail=1; }
grep -q 'UserPromptSubmit' "$proj/.claude/settings.json" && echo "ok   settings wires UserPromptSubmit" || { echo "FAIL UPS not wired"; fail=1; }
# Fixture hook reads stdin, not a /-leading argv (KH 1.1 immunity).
grep -q 'input=$(cat)' "$proj/.claude/hooks/ups-digest.sh" && echo "ok   fixture reads stdin (MSYS-safe)" || { echo "FAIL fixture not stdin-driven"; fail=1; }

# Fixture actually produces the digest marker when fed a real CC-shaped payload on stdin.
printf '%s' '{"session_id":"x","prompt":"hello-probe"}' | sh "$proj/.claude/hooks/ups-digest.sh"
sptc_ci_assert "fixture emits digest marker" "UPS_FIRED:hello-probe" "$digest" >/dev/null \
  && echo "ok   fixture emits digest marker" || { echo "FAIL fixture no marker"; fail=1; }

# ── Assert helper: negative case (missing needle) must FAIL, not false-pass.
if sptc_ci_assert "neg" "NOPE" "$digest" >/dev/null; then echo "FAIL assert false-passed"; fail=1; else echo "ok   assert fails on missing needle"; fi

# ── Orchestrator is env-gated: without SPTC_ACCEPTANCE it skips cleanly (rc 0), spawns no claude.
out=$(SPTC_ACCEPTANCE=0 sh "$(dirname "$0")/../ci/acceptance/run-acceptance.sh" 2>&1); orc=$?
{ [ "$orc" -eq 0 ] && printf '%s' "$out" | grep -q 'SKIP acceptance'; } \
  && echo "ok   orchestrator env-gate skips clean" || { echo "FAIL env-gate: rc=$orc out=[$out]"; fail=1; }

[ "$fail" -eq 0 ] && { echo "ACCEPTANCE-HARNESS OK"; exit 0; } || { echo "ACCEPTANCE-HARNESS FAIL"; exit 1; }

-[ "$got" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$got]"; fail=1; }

+sptc_ci_identity 7   # bare call (no $() — must mutate THIS shell)

+[ "$SPTC_CI_ID" = "sptc-ci-7" ] && echo "ok   mints disposable id" || { echo "FAIL id: got [$SPTC_CI_ID]"; fail=1; }


Bash

cd "$ROOT" 2>/dev/null; sh tests/acceptance-harness.sh; echo "rc=$?"

Re-run acceptance-harness unit test


ok   mints disposable id
ok   SPT_AGENT_ID overridden
ok   OWL_SESSION_ID overridden
ok   live id displaced
ok   accepts disposable
ok   rejects live id
ok   rejects empty id
ok   scaffold writes settings.json
ok   scaffold writes UPS hook
ok   settings wires UserPromptSubmit
ok   fixture reads stdin (MSYS-safe)
ok   fixture emits digest marker
ok   assert fails on missing needle
ok   orchestrator env-gate skips clean
ACCEPTANCE-HARNESS OK
rc=0


Green. Now docs/CI.md note (pre-push correct + disposable-identity invariant), then full gate.


28	   and must match what's checked in; a diff fails (see `docs/DOCS-STRATEGY.md`).
29
30	## Acceptance (the system-under-test is a real `claude` session)
31
32	Acceptance is **scripted orchestration that spawns real `claude` / headless sessions as the
33	system-under-test**, then asserts spt-state / `[digest]` output. The **LLM is the SUT, never
34	the runner** — the orchestration is deterministic; only the thing it drives is a real harness.
35	This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
36	GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
37	not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)
38
39	## Reporting bus = spt messaging (dogfood)

   and must match what's checked in; a diff fails (see `docs/DOCS-STRATEGY.md`).

## Acceptance (the system-under-test is a real `claude` session)

Acceptance is **scripted orchestration that spawns real `claude` / headless sessions as the
system-under-test**, then asserts spt-state / `[digest]` output. The **LLM is the SUT, never
the runner** — the orchestration is deterministic; only the thing it drives is a real harness.
This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)

## Reporting bus = spt messaging (dogfood)


Edit

This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)

This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)

**Disposable-identity invariant (mandatory).** Every nested `claude` the harness spawns runs
under a **throwaway perch id** (`SPT_AGENT_ID=sptc-ci-<n>`), never a live agent's name. A nested
session that resolves a live id establishes a perch under that name and — perches being
name-keyed, last-establish-wins — **tears down the live agent's perch and poll stream** (the
self-inflicted collision diagnosed 2026-06-15; see `docs/KNOWN-HAZARDS.md` §2.1 /
`REQ-HAZARD-PERCH-COLLISION`). `ci/acceptance/lib.sh` `sptc_ci_identity` enforces it.

**Slice 1** (`ci/acceptance/run-acceptance.sh`) asserts a real `claude` **fires the
UserPromptSubmit hook** via a hook-written digest marker — the harness-contract entry point
working inside a real harness — independent of the spt bus or model text. It is **env-gated**
(`SPTC_ACCEPTANCE=1`) so the deterministic gate run stays green on hosts without `claude`/auth.
Bus-delivery acceptance (poll→additionalContext over real spt) is a later slice, gated on
`REQ-MSG-ENVELOPE`.


The file C:\Users\decid\Documents\projects\spt-claude-code\docs\CI.md has been updated successfully. (file state is current in your context — no need to Read it back)

This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)

**Disposable-identity invariant (mandatory).** Every nested `claude` the harness spawns runs
under a **throwaway perch id** (`SPT_AGENT_ID=sptc-ci-<n>`), never a live agent's name. A nested
session that resolves a live id establishes a perch under that name and — perches being
name-keyed, last-establish-wins — **tears down the live agent's perch and poll stream** (the
self-inflicted collision diagnosed 2026-06-15; see `docs/KNOWN-HAZARDS.md` §2.1 /
`REQ-HAZARD-PERCH-COLLISION`). `ci/acceptance/lib.sh` `sptc_ci_identity` enforces it.

**Slice 1** (`ci/acceptance/run-acceptance.sh`) asserts a real `claude` **fires the
UserPromptSubmit hook** via a hook-written digest marker — the harness-contract entry point
working inside a real harness — independent of the spt bus or model text. It is **env-gated**
(`SPTC_ACCEPTANCE=1`) so the deterministic gate run stays green on hosts without `claude`/auth.
Bus-delivery acceptance (poll→additionalContext over real spt) is a later slice, gated on
`REQ-MSG-ENVELOPE`.

# CI model — agent-driven, autonomous, no-LLM-in-the-loop

> spt-claude-code's CI is **wholly agent-driven and autonomous** on the existing Windows +
> Linux fleet (hfenduleam + kitsubito). **GitHub-hosted runners are NOT used** — a stock
> hosted runner physically can't run Claude Code (auth + interactivity), so only a real
> harness on the fleet reaches the acceptance bar. **Deterministic gates** run by a fleet
> runner-agent, triggered by a git post-push hook, reporting over **spt messaging**. No LLM
> sits in the gate path — the gates are plain scripts; the "agent" is just the autonomous
> runner that fires them and reports.

## The gates (deterministic)
<!-- [doc->REQ-CI-GATES] -->
<!-- [doc->REQ-CI-OWL-DISCOVERY] -->
<!-- [doc->REQ-CI-MANUAL] -->
<!-- [doc->REQ-CI-TRIGGER] -->
<!-- [doc->REQ-CI-BUS] -->
<!-- [doc->REQ-CI-ACCEPTANCE] -->

Every gate is a script with a binary pass / fail — no judgment, no model:

1. **Build** — the project assembles clean.
2. **Unit tests** — the suite passes.
3. **`traceable-reqs check`** — requirement coverage gate; exit-1 fails the build (see
   `docs/TRACEABILITY.md`).
4. **Manifest-schema** — the CC adapter manifest validates against spt-core's **published**
   `manifest.schema.json` (from `SaberMage/spt-releases`).
5. **Docs-drift** — generated docs (API reference, `llms.txt`, schema, CLI help) are regenerated
   and must match what's checked in; a diff fails (see `docs/DOCS-STRATEGY.md`).

## Acceptance (the system-under-test is a real `claude` session)

Acceptance is **scripted orchestration that spawns real `claude` / headless sessions as the
system-under-test**, then asserts spt-state / `[digest]` output. The **LLM is the SUT, never
the runner** — the orchestration is deterministic; only the thing it drives is a real harness.
This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)

## Reporting bus = spt messaging (dogfood)

The reporting bus is **legacy spt** (`$OWL send`): the runner reports gate/acceptance results
over spt messaging to the responsible agent / channel. This dogfoods the product as its own CI
nervous system.

## Trigger: git post-push hook → ping a runner-agent over spt

The trigger is **push-driven, not polling** (polling adds latency and wastes cycles):

1. A **git post-push hook** fires after a push.
2. The hook **`$OWL send`s a one-line "run gates for `<ref>`" message** to a fleet runner-agent
   over spt messaging.
3. The runner-agent **runs the deterministic gates** (and acceptance) on the fleet.
4. The runner **reports the result back over spt** to the responsible agent / channel.

### Discovering the `$OWL send` (spt messaging) binary robustly

The bus binary's path **changes between versions** — legacy spt lives in a per-version
`~/.claude/` or `~/.ccs/` plugins folder whose path moves each release. No further
legacy-spt changes are anticipated, but the hook and runner **must locate it robustly**:
resolve it at run time (search the known plugins/install roots / a configured path / `PATH`)
rather than hard-coding a versioned location. A stale hard-coded path is the most likely cause
of a silently dead trigger.

## Manual fallback

A **manual "run gates" command** must always exist — the same gate scripts, runnable by hand on
any fleet host. Use it when the hook didn't fire, when reproducing a failure, or before a hook is
wired at all.

## Hosted-runner workflow (not used here)

A hosted CI workflow (e.g. GitHub Actions) is **explicitly dropped** for this project: it can't
run the real-harness acceptance bar. `docs/TRACEABILITY.md` carries a ready GitHub Actions
snippet for the pure-deterministic `traceable-reqs check` gate if a hosted fallback is ever
wanted, but the agent-driven fleet pattern is the primary and intended path.

+**Disposable-identity invariant (mandatory).** Every nested `claude` the harness spawns runs

+under a **throwaway perch id** (`SPT_AGENT_ID=sptc-ci-<n>`), never a live agent's name. A nested

+session that resolves a live id establishes a perch under that name and — perches being

+name-keyed, last-establish-wins — **tears down the live agent's perch and poll stream** (the

+self-inflicted collision diagnosed 2026-06-15; see `docs/KNOWN-HAZARDS.md` §2.1 /

+`REQ-HAZARD-PERCH-COLLISION`). `ci/acceptance/lib.sh` `sptc_ci_identity` enforces it.

+**Slice 1** (`ci/acceptance/run-acceptance.sh`) asserts a real `claude` **fires the

+UserPromptSubmit hook** via a hook-written digest marker — the harness-contract entry point

+working inside a real harness — independent of the spt bus or model text. It is **env-gated**

+(`SPTC_ACCEPTANCE=1`) so the deterministic gate run stays green on hosts without `claude`/auth.

+Bus-delivery acceptance (poll→additionalContext over real spt) is a later slice, gated on


58
59	## Trigger: git post-push hook → ping a runner-agent over spt
60
61	The trigger is **push-driven, not polling** (polling adds latency and wastes cycles):
62
63	1. A **git post-push hook** fires after a push.
64	2. The hook **`$OWL send`s a one-line "run gates for `<ref>`" message** to a fleet runner-agent
65	   over spt messaging.
66	3. The runner-agent **runs the deterministic gates** (and acceptance) on the fleet.
67	4. The runner **reports the result back over spt** to the responsible agent / channel.
68
69	### Discovering the `$OWL send` (spt messaging) binary robustly

## Trigger: git post-push hook → ping a runner-agent over spt

The trigger is **push-driven, not polling** (polling adds latency and wastes cycles):

1. A **git post-push hook** fires after a push.
2. The hook **`$OWL send`s a one-line "run gates for `<ref>`" message** to a fleet runner-agent
   over spt messaging.
3. The runner-agent **runs the deterministic gates** (and acceptance) on the fleet.
4. The runner **reports the result back over spt** to the responsible agent / channel.

### Discovering the `$OWL send` (spt messaging) binary robustly


## Trigger: git post-push hook → ping a runner-agent over spt

The trigger is **push-driven, not polling** (polling adds latency and wastes cycles):

1. A **git post-push hook** fires after a push.

## Trigger: git push hook → ping a runner-agent over spt

The trigger is **push-driven, not polling** (polling adds latency and wastes cycles).

> **Implemented as `pre-push`, by design — not a deviation.** Git has **no client-side
> `post-push` hook**; the only push-time client trigger is `pre-push` (a true post-receive lives
> server-side, which the autonomous fleet is not). SCOPE's "post-push" is loose wording for
> "push-driven." The hook (`ci/git-hooks/pre-push`) is non-fatal: a missing bus or unset runner
> never blocks the push. (Confirmed correct by doyle, 2026-06-15.)

1. The **git `pre-push` hook** fires at push time.


## Trigger: git push hook → ping a runner-agent over spt

The trigger is **push-driven, not polling** (polling adds latency and wastes cycles).

> **Implemented as `pre-push`, by design — not a deviation.** Git has **no client-side
> `post-push` hook**; the only push-time client trigger is `pre-push` (a true post-receive lives
> server-side, which the autonomous fleet is not). SCOPE's "post-push" is loose wording for
> "push-driven." The hook (`ci/git-hooks/pre-push`) is non-fatal: a missing bus or unset runner
> never blocks the push. (Confirmed correct by doyle, 2026-06-15.)

1. The **git `pre-push` hook** fires at push time.

# CI model — agent-driven, autonomous, no-LLM-in-the-loop

> spt-claude-code's CI is **wholly agent-driven and autonomous** on the existing Windows +
> Linux fleet (hfenduleam + kitsubito). **GitHub-hosted runners are NOT used** — a stock
> hosted runner physically can't run Claude Code (auth + interactivity), so only a real
> harness on the fleet reaches the acceptance bar. **Deterministic gates** run by a fleet
> runner-agent, triggered by a git post-push hook, reporting over **spt messaging**. No LLM
> sits in the gate path — the gates are plain scripts; the "agent" is just the autonomous
> runner that fires them and reports.

## The gates (deterministic)
<!-- [doc->REQ-CI-GATES] -->
<!-- [doc->REQ-CI-OWL-DISCOVERY] -->
<!-- [doc->REQ-CI-MANUAL] -->
<!-- [doc->REQ-CI-TRIGGER] -->
<!-- [doc->REQ-CI-BUS] -->
<!-- [doc->REQ-CI-ACCEPTANCE] -->

Every gate is a script with a binary pass / fail — no judgment, no model:

1. **Build** — the project assembles clean.
2. **Unit tests** — the suite passes.
3. **`traceable-reqs check`** — requirement coverage gate; exit-1 fails the build (see
   `docs/TRACEABILITY.md`).
4. **Manifest-schema** — the CC adapter manifest validates against spt-core's **published**
   `manifest.schema.json` (from `SaberMage/spt-releases`).
5. **Docs-drift** — generated docs (API reference, `llms.txt`, schema, CLI help) are regenerated
   and must match what's checked in; a diff fails (see `docs/DOCS-STRATEGY.md`).

## Acceptance (the system-under-test is a real `claude` session)

Acceptance is **scripted orchestration that spawns real `claude` / headless sessions as the
system-under-test**, then asserts spt-state / `[digest]` output. The **LLM is the SUT, never
the runner** — the orchestration is deterministic; only the thing it drives is a real harness.
This is what a stock hosted runner can't do, and why the fleet is mandatory. (spt-core's
GH-runner justification — heavy multi-platform Rust + signed releases + two-host net — does
not carry here: the adapter binary is thin glue and delegates releases/signing to `spt`.)

**Disposable-identity invariant (mandatory).** Every nested `claude` the harness spawns runs
under a **throwaway perch id** (`SPT_AGENT_ID=sptc-ci-<n>`), never a live agent's name. A nested
session that resolves a live id establishes a perch under that name and — perches being
name-keyed, last-establish-wins — **tears down the live agent's perch and poll stream** (the
self-inflicted collision diagnosed 2026-06-15; see `docs/KNOWN-HAZARDS.md` §2.1 /
`REQ-HAZARD-PERCH-COLLISION`). `ci/acceptance/lib.sh` `sptc_ci_identity` enforces it.

**Slice 1** (`ci/acceptance/run-acceptance.sh`) asserts a real `claude` **fires the
UserPromptSubmit hook** via a hook-written digest marker — the harness-contract entry point
working inside a real harness — independent of the spt bus or model text. It is **env-gated**
(`SPTC_ACCEPTANCE=1`) so the deterministic gate run stays green on hosts without `claude`/auth.
Bus-delivery acceptance (poll→additionalContext over real spt) is a later slice, gated on
`REQ-MSG-ENVELOPE`.

## Reporting bus = spt messaging (dogfood)

The reporting bus is **legacy spt** (`$OWL send`): the runner reports gate/acceptance results
over spt messaging to the responsible agent / channel. This dogfoods the product as its own CI
nervous system.

## Trigger: git post-push hook → ping a runner-agent over spt

The trigger is **push-driven, not polling** (polling adds latency and wastes cycles):

1. A **git post-push hook** fires after a push.
2. The hook **`$OWL send`s a one-line "run gates for `<ref>`" message** to a fleet runner-agent
   over spt messaging.
3. The runner-agent **runs the deterministic gates** (and acceptance) on the fleet.
4. The runner **reports the result back over spt** to the responsible agent / channel.

### Discovering the `$OWL send` (spt messaging) binary robustly

The bus binary's path **changes between versions** — legacy spt lives in a per-version
`~/.claude/` or `~/.ccs/` plugins folder whose path moves each release. No further
legacy-spt changes are anticipated, but the hook and runner **must locate it robustly**:
resolve it at run time (search the known plugins/install roots / a configured path / `PATH`)
rather than hard-coding a versioned location. A stale hard-coded path is the most likely cause
of a silently dead trigger.

## Manual fallback

A **manual "run gates" command** must always exist — the same gate scripts, runnable by hand on
any fleet host. Use it when the hook didn't fire, when reproducing a failure, or before a hook is
wired at all.

## Hosted-runner workflow (not used here)

A hosted CI workflow (e.g. GitHub Actions) is **explicitly dropped** for this project: it can't
run the real-harness acceptance bar. `docs/TRACEABILITY.md` carries a ready GitHub Actions
snippet for the pure-deterministic `traceable-reqs check` gate if a hosted fallback is ever
wanted, but the agent-driven fleet pattern is the primary and intended path.

-## Trigger: git post-push hook → ping a runner-agent over spt

+## Trigger: git push hook → ping a runner-agent over spt

-The trigger is **push-driven, not polling** (polling adds latency and wastes cycles):

+The trigger is **push-driven, not polling** (polling adds latency and wastes cycles).

-1. A **git post-push hook** fires after a push.

+> **Implemented as `pre-push`, by design — not a deviation.** Git has **no client-side

+> `post-push` hook**; the only push-time client trigger is `pre-push` (a true post-receive lives

+> server-side, which the autonomous fleet is not). SCOPE's "post-push" is loose wording for

+> "push-driven." The hook (`ci/git-hooks/pre-push`) is non-fatal: a missing bus or unset runner

+> never blocks the push. (Confirmed correct by doyle, 2026-06-15.)

+1. The **git `pre-push` hook** fires at push time.


Now full gate run.


Bash

cd "$ROOT" 2>/dev/null; sh ci/run-gates.sh 2>&1; echo "GATES_RC=$?"; echo "=== traceable-reqs check ==="; traceable-reqs check 2>&1; echo "TR_RC=$?"

Run full gates + traceable-reqs check