From 0d17d2d0228c234778c7343462a406c0b081ead4 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 01:37:17 +0700 Subject: [PATCH 01/10] Add Codex DM built-app provider path --- WorldOS-GUI-RUNBOOK.md | 30 +- WorldOS-OPERATING-GOAL.md | 61 ++- WorldOS-RUNBOOK.md | 15 +- .../Services/AppProcessService.swift | 17 + .../Services/ProviderAdapters.swift | 28 +- .../Sources/WorldOSApp/Views/RootView.swift | 19 + qa/test_macos_app_static.py | 14 + scripts/play_codex_actor.sh | 12 +- scripts/play_codex_dm.sh | 404 ++++++++++++++++++ .../tests/test_codex_provider_wrapper.py | 193 +++++++++ viewer/openworlds/screen-create.jsx | 6 +- viewer/openworlds/screen-launcher.jsx | 6 +- viewer/openworlds/screen-roster.jsx | 10 +- viewer/server.py | 11 +- viewer/tests/test_openworlds_static.py | 21 + viewer/tests/test_roster_surface.py | 18 + 16 files changed, 805 insertions(+), 60 deletions(-) create mode 100755 scripts/play_codex_dm.sh diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index 433acf93..559fb6fe 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -8,7 +8,7 @@ > `qa/release_readiness.py` (the RRI scorer), `qa/SCORECARD.md` (the ledger). > > Takeover routing, 2026-05-31: `/Users/lume/ClawDnD-val` is the synced local app/private-art checkout -> (`5dd1391 == origin/main` after #472) and the default place to build/run/test the GUI and native app. +> (`6e03da4 == origin/main` after #473) and the default place to build/run/test the GUI and native app. > Lexar is for evidence/snapshots/logs, not the default runtime tree, because macOS permission prompts > can break AI/browser tests when assets live on the external drive. For tracked GUI edits, prefer a > same-disk local worktree; use Lexar worktrees only for non-GUI slices that will not launch against art. @@ -30,11 +30,12 @@ - OpenWorlds native-start surfaces now honor the macOS app's selected provider (#472). If the web UI has not loaded app status yet, it omits `provider` and lets Swift's `selectedProviderRaw` setting decide. -- Do not treat that as a Claude-free release proof. The default Codex wrapper - (`scripts/play_codex_actor.sh`) is a constrained **player actor** using the player facade; it is not yet - a Dungeon Master loop. OpenClaw is also not launchable until a valid provider command is configured. -- Before running #466, prove first-turn built-app play with a provider that actually mints the world, - writes DM narration, and leaves `/session-surface` with `can_act:true` for the live/current campaign. +- The Codex path now has two wrappers: `scripts/play_codex_dm.sh` for the selected provider's DM loop, + and `scripts/play_codex_actor.sh` for constrained player/companion actor work. Do not swap them. +- Do not treat the wrapper as release proof by itself. The 2026-06-01 local built-app proof shows + it can mint the world, write DM narration, submit one player move, and leave `/session-surface` + with `can_act:true` for the live/current campaign. Release still requires a short built-app playtest + plus the full non-partial RRI gate. ## Stand up the iteration surface (8799, playable, from canonical) ```bash @@ -89,6 +90,23 @@ palette run. Append every `--scorecard-row` line to `qa/SCORECARD.md` as diagnostic release evidence. Only a non-partial, non-harness-contaminated 10/10 row with no evidence gaps can count as release evidence. +## macOS privacy prompt triage + +During local proof runs, a macOS Photos/Music prompt can be a **test-process attribution artifact**: +TCC may name the frontmost WorldOS app as `responsible` even when the actual `accessing` process is a +diagnostic command such as `/usr/bin/find` or `codex`. Before filing this as a product blocker, inspect +the attribution: + +```bash +/usr/bin/log show --style compact --last 10m \ + --predicate 'eventMessage CONTAINS[c] "dev.clawdnd" OR eventMessage CONTAINS[c] "kTCCServicePhotos" OR eventMessage CONTAINS[c] "kTCCServiceMediaLibrary"' +``` + +If `AUTHREQ_ATTRIBUTION` shows `accessing=/usr/bin/find` or `accessing=codex`, classify it as harness +contamination and rerun proof without broad filesystem scans while the app is frontmost. If it shows +`WorldOSApp` or a WebKit child process directly accessing a protected Photos/Music path, treat it as a +release-blocking product bug. + Non-disruptive Mac smoke during takeover: ```bash WORLDOS_NO_STOP_EXISTING=1 \ diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index 7ab8698c..ad46e73d 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,9 +5,9 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-05-31 post-PR #472 local provider-selection sync + AS OF: 2026-06-01 local Codex-DM built-app first-turn proof MAIN BASELINE: - 5dd1391 (PRs #470, #471, #472 merged; verified in /Users/lume/ClawDnD-val on 2026-05-31). + 6e03da4 (PRs #470, #471, #472, #473 merged; verified in /Users/lume/ClawDnD-val on 2026-06-01). Re-verify current `origin/main` before acting. CANONICAL: /Users/lume/ClawDnD-val is now the synced local app/private-art checkout and the default place to build/run/test the Mac app. Keep GUI/runtime tests on this @@ -24,11 +24,19 @@ HARNESS-CONTAMINATED evidence: only newbie wrote score.json; the other personas failed around port/backend harness setup; behavioral/UI/palette/image evidence was not a valid five-persona release verdict. + LAST BUILT-APP PLAY PROOF: + 2026-06-01 local `dist/WorldOS.app` proof with private BG art, Codex DM + provider, visible narration, enabled player actions, submitted `/move`, and + post-move DM response. Evidence lives under + /Volumes/LEXAR/Codex/worldos-built-app-proof/ + (`session-surface-racefix-after-dm-response-20260601T012410.json` and + `worldos-racefix-dm-response-dismissed-permission-20260601T012516.png`). LAST VALID RELEASE GATE: none after the RRI contract hardening. A release verdict requires expected persona count, disk-backed palette/image/behavioral evidence, and built .app play. - NEXT ACTION: From the synced local checkout, prove first-turn built-app play with an actual - DM-capable provider, then run #466 for a trustworthy clean RRI failure list/result. + NEXT ACTION: Run a short built-app gameplay playtest from the proven Codex-DM path, file/fix + the user-facing blockers it exposes, then run #466 for a trustworthy clean RRI + failure list/result on the 32GB support VM plus Mac-only built-app smoke. Keep sprint work UX-first (#467): first-turn playability, clickability/chrome, launcher clarity, live-response feel, and CRPG depth before more hardening/proxy/security work. DISCIPLINE: ≥2 clean reads before any fix (channel fabricates under host load); ONE heavy claude -p stream; @@ -168,13 +176,14 @@ verifier; can revert the goal to "fix" anytime. --- -## 9. CURRENT STATUS (2026-05-31 — local checkout synced, NOT a release verdict) +## 9. CURRENT STATUS (2026-06-01 — first-turn proof achieved, NOT a release verdict) - Repo truth stabilization merged in PR #465, UX-first doc sync merged in PR #468, and first-minute click/title chrome proof merged in PR #470. Local/Lexar/support-VM routing merged in PR #471. Native OpenWorlds starts now honor the macOS app's selected provider instead of forcing Claude - from the web surface in PR #472. The local app/private-art checkout `/Users/lume/ClawDnD-val` - is synced to `5dd1391 == origin/main` as of 2026-05-31. + from the web surface in PR #472. PR #473 synced the takeover docs after provider selection. + The local app/private-art checkout `/Users/lume/ClawDnD-val` is synced to + `6e03da4 == origin/main` as of 2026-06-01. - The stale local pre-sync artifacts were preserved before the fast-forward at `/Volumes/LEXAR/Codex/worldos-local-checkout-snapshot-20260531T223923` and in `stash@{0}` (`pre-sync local takeover docs 2026-05-31`). Treat those as evidence, not current release truth. @@ -183,19 +192,30 @@ verifier; can revert the goal to "fix" anytime. image/palette/behavioral/UI audit sources were either missing or harness-contaminated. - Built-app launch smoke on `cad2e00` rendered OpenWorlds with private art, but the first Resume/Play click still forced Claude and failed on Claude auth. PR #472 fixed that web/native selection bug. - It does **not** prove a Claude-free release path yet: the checked-in Codex wrapper is a constrained - player actor/move-facade, and OpenClaw requires an intentionally configured provider command. -- The next evidence step is issue #466 after the first-turn provider path is DM-capable: run a clean - non-partial five-persona RRI from `5dd1391` or newer. Heavy backend/persona sweeps belong on the - owner-provided 32GB support VM (`support-vm-1`) once auth/config are intentionally installed there; - connection details are kept outside tracked docs. Mac-only built-app launch/play proof stays on this - Mac or macOS CI. + The current #474 slice adds a separate Codex DM wrapper and points the native Codex adapter at it, + while keeping the older Codex actor wrapper as a constrained player/move-facade helper. +- The new Codex-DM path has now produced local built-app first-turn evidence: private BG art loaded, + Alfira seated as `player`, visible DM narration, enabled actions, a real player move appended to + `player_moves.jsonl`, and a post-move DM response with `can_act:true` in `/session-surface`. + Evidence is in `/Volumes/LEXAR/Codex/worldos-built-app-proof/`: + `session-surface-racefix-after-dm-response-20260601T012410.json`, + `worldos-racefix-first-turn-20260601T012110.png`, and + `worldos-racefix-dm-response-dismissed-permission-20260601T012516.png`. +- A Photos/Music permission prompt seen during this proof was traced through unified logs to + macOS TCC attribution contamination: `responsible=dev.clawdnd.app`, but the actual accessor was + `/usr/bin/find` launched by the test/diagnostic environment. Treat that screenshot prompt as harness + contamination unless a clean run shows `WorldOSApp`/WebKit itself accessing a protected library path. +- The next evidence step is a short built-app gameplay playtest from this proven provider path, followed + by issue #466: a clean non-partial five-persona RRI from `6e03da4` or newer. Heavy backend/persona + sweeps belong on the owner-provided 32GB support VM (`support-vm-1`) once auth/config are intentionally + installed there; connection details are kept outside tracked docs. Mac-only built-app launch/play proof + stays on this Mac or macOS CI. - Product direction is now UX-first (#467). Do not turn the next sprint into more gate hardening, proxy adapters, transport/security work, UGC/legal, or renderer branches unless #466 proves they block the player-facing session. The game must feel launchable, clickable, responsive, and deep before it needs more machinery. -- Highest-confidence UX risks to verify/fix next: first-turn built-app play; broader click hit areas (#309) - after #470's shared-chrome proof; built-app title/chrome truth (#306); launcher clarity/stale campaigns - (#358); per-beat latency/live response (#393); portrait/gallery blockers (#379); and CRPG depth on +- Highest-confidence UX risks to verify/fix next: broader click hit areas (#309) after #470's + shared-chrome proof; built-app title/chrome truth (#306); launcher clarity/stale campaigns (#358); + per-beat latency/live response (#393); portrait/gallery blockers (#379); and CRPG depth on Heroes/Battle/Inventory (#308/#318/#310, with #462/#463 folded into Battle readability as presentation containment). --- @@ -204,9 +224,10 @@ verifier; can revert the goal to "fix" anytime. Use the gate as evidence, not as the roadmap. The next sprint should optimize the felt session: -1. **Prove first-turn play in the built app.** A fresh player can launch, choose/start/resume, reach the - Table, submit one `/move`, and see narration resolve without critical console/runtime errors. Evidence: - issue #466 artifacts, not a proxy preview. +1. **Stretch first-turn proof into a short built-app playtest.** A fresh player can launch, + choose/start/resume, reach the Table, submit multiple `/move`s, and see narration resolve without + critical console/runtime errors. Evidence: built-app screenshots plus `/session-surface` and move/chat + artifacts, not a proxy preview. 2. **Fix the "this is not clickable" feeling.** Close #309 only when clicking any visible tab/button background works with mouse and keyboard. Pair with visual truth for #306 so the title/day/chrome no longer look broken at common widths. diff --git a/WorldOS-RUNBOOK.md b/WorldOS-RUNBOOK.md index 9e5bd20e..a34847db 100644 --- a/WorldOS-RUNBOOK.md +++ b/WorldOS-RUNBOOK.md @@ -25,7 +25,7 @@ > If an operator hands you local session notes or decision records, treat them as > private working artifacts unless they are intentionally promoted into tracked docs. > -> Last updated: 2026-05-31 (takeover routing refresh; release notes below are historical context). +> Last updated: 2026-06-01 (Codex-DM built-app first-turn proof + TCC test-contamination note; release notes below are historical context). > > **Graphics & game-types roadmap (canonical):** the long-term plan for the kinds of games > WorldOS can produce (GT0 narrative dashboard → GT1 SNES pixel → GT2 Pillars/BG isometric) @@ -306,8 +306,8 @@ out freely. Only **`claude -p` QA is host-heavy** (the duo/sprint spin up engine **Historical snapshot, not current authority:** this queue was written around `ea815fc` (2026-05-27 cont.3). During the 2026-05-31 takeover, the gate-truth stabilization merged as PR #465, the UX-first doc sync merged as PR #468, and first-minute click/title chrome proof merged as PR #470. -Local routing sync merged as PR #471, and native provider-selection sync merged as PR #472. The local -app/private-art checkout is now synced at `5dd1391 == origin/main`; the only current gate +Local routing sync merged as PR #471, native provider-selection sync merged as PR #472, and takeover +state docs synced as PR #473. The local app/private-art checkout is now synced at `6e03da4 == origin/main`; the only current gate truth lives in `WorldOS-OPERATING-GOAL.md` + `WorldOS-GUI-RUNBOOK.md` + `qa/SCORECARD.md`. Do not use this section to decide release state. The next sprint is UX-first (#467): prove first-turn built-app play via #466, then prioritize clickability/chrome, launcher clarity, @@ -389,10 +389,11 @@ lands on app relaunch with NO Swift rebuild** (the swift build is a ~0.1s no-op) it binds a viewer with `CLAWDND_PLAYER_MOVES` + `CLAWDND_VIEWER_CHAT` set (→ `_live_play()` true) and runs a `claude -p` DM watching the move sink. `POST /move` → sink; `/chat?since=` → DM narration the Session tails. -- The checked-in Codex provider wrapper is **not** a DM substitute yet: `scripts/play_codex_actor.sh` - runs Codex as a constrained player actor through `player_server.py`. It can validate the provider - environment and move-facade contract, but it does not mint the world or write DM narration. OpenClaw - requires an explicit configured command before it can be treated as a startable provider. +- The checked-in Codex provider now defaults to `scripts/play_codex_dm.sh`, a DM wrapper that owns + the live viewer, the engine/rules/voice MCP contract, `chat.jsonl`, and `player_moves.jsonl`. + Keep `scripts/play_codex_actor.sh` as the constrained player/companion actor helper through + `player_server.py`; it is not the native provider's DM loop. OpenClaw still requires an explicit + configured command before it can be treated as a startable provider. - **`can_act = _live_play() AND is_live_view`**, and `is_live_view` requires `cid == self.campaign_id`. The viewer launches with an EMPTY campaign id; `_resolve_campaign` lazily sets `self.campaign_id` to the **current** campaign (`_pick_campaign`). So the diff --git a/macos/WorldOSApp/Sources/WorldOSApp/Services/AppProcessService.swift b/macos/WorldOSApp/Sources/WorldOSApp/Services/AppProcessService.swift index 6ea25181..5341e94b 100644 --- a/macos/WorldOSApp/Sources/WorldOSApp/Services/AppProcessService.swift +++ b/macos/WorldOSApp/Sources/WorldOSApp/Services/AppProcessService.swift @@ -17,6 +17,17 @@ final class AppProcessService: ObservableObject { private let registry = ProviderRegistry() private let maxLogCharacters = 120_000 + var activeProviderOpenWorldsURL: URL? { + guard runningProvider != nil, + let endpoint = viewerEndpoint, + endpoint.name == "Provider viewer", + endpoint.status != .stopped + else { + return nil + } + return endpoint.openWorldsURL + } + var diagnostics: String { """ WorldOS Native App Diagnostics @@ -229,6 +240,12 @@ final class AppProcessService: ObservableObject { return endpoint.openWorldsURL } + func markProviderViewerReady() { + guard var endpoint = viewerEndpoint, endpoint.name == "Provider viewer" else { return } + endpoint.status = .running + viewerEndpoint = endpoint + } + func stopProvider() { if let providerProcess { intentionallyStoppingProviderPIDs.insert(providerProcess.pid) diff --git a/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift b/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift index a3fe38f4..b34f377f 100644 --- a/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift +++ b/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift @@ -95,7 +95,8 @@ struct CodexProvider: ProviderAdapter { func detect(repoPath: URL, preferences: ProviderPreferences) -> ProviderStatus { let cli = Shell.which("codex") - let wrapper = repoPath.appendingPathComponent("scripts/play_codex_actor.sh") + let wrapper = repoPath.appendingPathComponent("scripts/play_codex_dm.sh") + let actorHelper = repoPath.appendingPathComponent("scripts/play_codex_actor.sh") let configuredCommand = preferences.codexCommand.trimmingCharacters(in: .whitespacesAndNewlines) guard let cli else { @@ -111,7 +112,7 @@ struct CodexProvider: ProviderAdapter { return ProviderStatus( kind: kind, availability: .error, - detail: "Codex CLI found, but scripts/play_codex_actor.sh is missing from this checkout.", + detail: "Codex CLI found, but scripts/play_codex_dm.sh is missing from this checkout.", detectedPath: cli ) } @@ -120,8 +121,8 @@ struct CodexProvider: ProviderAdapter { kind: kind, availability: .configured, detail: configuredCommand.isEmpty - ? "Ready. Launches the checked-in Codex wrapper with the WorldOS provider environment and player-facade-only tool surface." - : "Ready. Launches your configured Codex command with the WorldOS provider environment and player-facade-only tool surface.", + ? "Ready. Launches the checked-in Codex DM wrapper with the WorldOS provider environment. Actor helper: \(actorHelper.path)." + : "Ready. Launches your configured Codex command with the WorldOS provider environment.", detectedPath: configuredCommand.isEmpty ? wrapper.path : cli ) } @@ -135,16 +136,13 @@ struct CodexProvider: ProviderAdapter { repoPath: URL, preferences: ProviderPreferences ) throws -> ProviderLaunchRequest { - // hero (authored-PC spec) is only consumed by the Claude play path today; accepted here - // to satisfy the protocol and ignored. - _ = hero guard Shell.which("codex") != nil else { throw ProviderError.missingDependency("Codex CLI is missing. Install codex before starting a Codex provider session.") } - let wrapper = repoPath.appendingPathComponent("scripts/play_codex_actor.sh") + let wrapper = repoPath.appendingPathComponent("scripts/play_codex_dm.sh") guard FileManager.default.fileExists(atPath: wrapper.path) else { - throw ProviderError.configuration("Codex provider wrapper is missing: scripts/play_codex_actor.sh") + throw ProviderError.configuration("Codex provider wrapper is missing: scripts/play_codex_dm.sh") } let configuredCommand = preferences.codexCommand.trimmingCharacters(in: .whitespacesAndNewlines) @@ -159,6 +157,7 @@ struct CodexProvider: ProviderAdapter { runId: runId, port: port, companions: companions, + hero: hero, preferences: preferences ), workingDirectory: repoPath, @@ -173,7 +172,7 @@ struct CodexProvider: ProviderAdapter { } private var defaultCodexCommand: String { - "scripts/play_codex_actor.sh" + "scripts/play_codex_dm.sh" } } @@ -224,9 +223,6 @@ struct OpenClawProvider: ProviderAdapter { repoPath: URL, preferences: ProviderPreferences ) throws -> ProviderLaunchRequest { - // hero (authored-PC spec) is only consumed by the Claude play path today; accepted here - // to satisfy the protocol and ignored. - _ = hero let command = preferences.openClawCommand.trimmingCharacters(in: .whitespacesAndNewlines) guard !command.isEmpty else { throw ProviderError.configuration("OpenClaw provider is not launch-configured. Set an OpenClaw provider command in Settings.") @@ -241,6 +237,7 @@ struct OpenClawProvider: ProviderAdapter { runId: runId, port: port, companions: companions, + hero: hero, preferences: preferences ), workingDirectory: repoPath, @@ -298,6 +295,7 @@ private func providerEnvironment( runId: String, port: Int, companions: String, + hero: String = "", preferences: ProviderPreferences ) -> [String: String] { var env = budgetEnvironment(preferences) @@ -306,5 +304,9 @@ private func providerEnvironment( env["CLAWDND_RUN_ID"] = runId env["CLAWDND_PLAY_PORT"] = String(port) env["CLAWDND_PLAY_COMPANIONS"] = companions + let trimmedHero = hero.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedHero.isEmpty { + env["CLAWDND_PLAY_HERO"] = trimmedHero + } return env } diff --git a/macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift b/macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift index 940b71f4..db8b7e4a 100644 --- a/macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift +++ b/macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift @@ -91,8 +91,15 @@ struct RootView: View { } private func startOpenWorlds() { + if keepActiveProviderViewerAttached() { + return + } launchTask?.cancel() launchTask = Task { @MainActor in + if keepActiveProviderViewerAttached() { + isStarting = false + return + } isStarting = true launchError = nil webViewErrorMessage = nil @@ -118,6 +125,17 @@ struct RootView: View { } } + private func keepActiveProviderViewerAttached() -> Bool { + guard let url = processService.activeProviderOpenWorldsURL else { return false } + launchTask?.cancel() + launchError = nil + webViewErrorMessage = nil + isStarting = false + webURL = url + launchMessage = "Provider session active" + return true + } + private func waitForOpenWorlds(_ url: URL) async throws { // Host strain can push the viewer's first bind well past the old 8s budget even // when the port is free; 25s tolerates a slow start instead of falsely reporting @@ -246,6 +264,7 @@ struct RootView: View { preferences: providerPreferences ) try await waitForOpenWorlds(url) + processService.markProviderViewerReady() webURL = url return appStatusPayload(extra: ["url": url.absoluteString, "runId": runId]) } diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index 1399c40c..f9ac695f 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -50,6 +50,20 @@ def test_native_viewer_and_provider_forward_art_repo_env(self): self.assertIn('env["WORLDOS_ART_REPO_ROOT"] = preferences.artRepoPath', providers) self.assertIn('env["CLAWDND_ART_REPO_ROOT"] = preferences.artRepoPath', providers) + def test_provider_viewer_stays_attached_during_native_restarts(self): + root_view = self.read("macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift") + app_process = self.read("macos/WorldOSApp/Sources/WorldOSApp/Services/AppProcessService.swift") + + self.assertIn("activeProviderOpenWorldsURL", app_process) + self.assertIn('endpoint.name == "Provider viewer"', app_process) + self.assertIn("markProviderViewerReady()", app_process) + self.assertIn("keepActiveProviderViewerAttached()", root_view) + self.assertRegex( + root_view, + r"private func startOpenWorlds\(\) \{\s*if keepActiveProviderViewerAttached\(\)", + ) + self.assertIn('launchMessage = "Provider session active"', root_view) + if __name__ == "__main__": unittest.main() diff --git a/scripts/play_codex_actor.sh b/scripts/play_codex_actor.sh index 21155f30..a03fe7c3 100755 --- a/scripts/play_codex_actor.sh +++ b/scripts/play_codex_actor.sh @@ -138,7 +138,7 @@ out_path.write_text( "my_sheet", ] ), - 'default_tools_approval_mode = "auto"', + 'default_tools_approval_mode = "approve"', "", ] ), @@ -196,7 +196,11 @@ if [ "$MODE" != "run" ]; then exit 0 fi -CODEX_MODEL="${CLAWDND_CODEX_MODEL:-gpt-5.1-codex-max}" +CODEX_MODEL="${CLAWDND_CODEX_MODEL:-}" +MODEL_ARGS=() +if [ -n "${CODEX_MODEL//[[:space:]]/}" ]; then + MODEL_ARGS=(--model "$CODEX_MODEL") +fi export CLAWDND_STATE_DIR="$RUN_DIR" export CLAWDND_PLAYER_MOVES="$MOVES" export CLAWDND_ACTOR_ID="${CLAWDND_ACTOR_ID:-}" @@ -206,15 +210,15 @@ codex exec \ --ignore-user-config \ --ignore-rules \ --sandbox read-only \ - --ask-for-approval never \ --json \ - --model "$CODEX_MODEL" \ + ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \ --cd "$ROOT" \ --output-last-message "$LAST_MESSAGE" \ -c "mcp_servers.clawdnd-player.command=\"uv\"" \ -c "mcp_servers.clawdnd-player.args=[\"run\",\"--directory\",\"$ROOT/servers/engine\",\"python\",\"player_server.py\"]" \ -c "mcp_servers.clawdnd-player.env_vars=[\"CLAWDND_STATE_DIR\",\"CLAWDND_PLAYER_MOVES\",\"CLAWDND_ACTOR_ID\",\"CLAWDND_ACTOR_ROLE\"]" \ -c "mcp_servers.clawdnd-player.required=true" \ + -c "mcp_servers.clawdnd-player.default_tools_approval_mode=\"approve\"" \ -c "mcp_servers.clawdnd-player.enabled_tools=[\"say\",\"do\",\"clarify\",\"request_check\",\"cast_spell\",\"use_item\",\"attack\",\"look\",\"my_sheet\"]" \ - < "$PROMPT_FILE" \ > >(tee -a "$STDOUT_LOG") \ diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh new file mode 100755 index 00000000..58ad4e1e --- /dev/null +++ b/scripts/play_codex_dm.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Run Codex as a Dungeon Master provider for a live WorldOS/OpenWorlds session. +# +# This is the DM counterpart to play_codex_actor.sh. The actor wrapper is a +# constrained player facade; this wrapper owns the live viewer, full engine/rules +# MCP contract, DM narration chat, and player move resolution loop. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +fail() { + echo "[codex-dm-provider] $*" >&2 + exit 2 +} + +MODE="run" +case "${1:-}" in + --dry-run) MODE="dry-run"; shift ;; + --smoke) MODE="smoke"; shift ;; + -h|--help) + cat <<'EOF' +Usage: scripts/play_codex_dm.sh [--dry-run|--smoke] + +Required environment: + CLAWDND_PROVIDER=codex + CLAWDND_WORLD + CLAWDND_RUN_ID + CLAWDND_PLAY_PORT + CLAWDND_PLAY_BUDGET + CLAWDND_PLAY_SESSION_BUDGET + CLAWDND_PLAY_MAX_TURNS + +Optional: + CLAWDND_PLAY_COMPANIONS + CLAWDND_PLAY_HERO + CLAWDND_CODEX_MODEL + CLAWDND_STATE_ROOT +EOF + exit 0 + ;; + --*) fail "unknown option: $1" ;; +esac +[ "$#" -eq 0 ] || fail "unexpected argument: $1" + +require_env() { + local name="$1" value="${!1:-}" + [ -n "${value//[[:space:]]/}" ] || fail "missing required env: $name" +} + +require_env CLAWDND_PROVIDER +PROVIDER_LOWER="$(printf '%s' "$CLAWDND_PROVIDER" | tr '[:upper:]' '[:lower:]')" +[ "$PROVIDER_LOWER" = "codex" ] || fail "CLAWDND_PROVIDER must be codex" +require_env CLAWDND_WORLD +require_env CLAWDND_RUN_ID +require_env CLAWDND_PLAY_PORT +require_env CLAWDND_PLAY_BUDGET +require_env CLAWDND_PLAY_SESSION_BUDGET +require_env CLAWDND_PLAY_MAX_TURNS + +[[ "$CLAWDND_PLAY_PORT" =~ ^[0-9]+$ ]] || fail "CLAWDND_PLAY_PORT must be an integer" +if [ "$CLAWDND_PLAY_PORT" -lt 1 ] || [ "$CLAWDND_PLAY_PORT" -gt 65535 ]; then + fail "CLAWDND_PLAY_PORT out of range: $CLAWDND_PLAY_PORT" +fi +[[ "$CLAWDND_PLAY_MAX_TURNS" =~ ^[0-9]+$ ]] || fail "CLAWDND_PLAY_MAX_TURNS must be an integer" +for budget_name in CLAWDND_PLAY_BUDGET CLAWDND_PLAY_SESSION_BUDGET; do + [[ "${!budget_name}" =~ ^[0-9]+([.][0-9]+)?$ ]] || fail "$budget_name must be a positive decimal" +done +[[ "$CLAWDND_RUN_ID" =~ ^[A-Za-z0-9._-]+$ ]] || fail "CLAWDND_RUN_ID may only contain letters, numbers, '.', '_' and '-'" + +command -v python3 >/dev/null 2>&1 || fail "python3 is required" +command -v uv >/dev/null 2>&1 || fail "uv is required" +command -v jq >/dev/null 2>&1 || fail "jq is required" +if [ "$MODE" = "run" ]; then + command -v codex >/dev/null 2>&1 || fail "codex CLI is required for real provider runs" +fi + +if [ "$MODE" = "smoke" ]; then + STATE_ROOT="${CLAWDND_STATE_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/clawdnd-codex-dm-smoke.XXXXXX")}" +else + STATE_ROOT="${CLAWDND_STATE_ROOT:-$ROOT/play-state}" +fi +STATE_ROOT="$(python3 - "$STATE_ROOT" <<'PY' +import sys +from pathlib import Path +print(Path(sys.argv[1]).expanduser().resolve(strict=False)) +PY +)" + +RUN_DIR="$STATE_ROOT/$CLAWDND_RUN_ID" +PROVIDER_DIR="$RUN_DIR/codex-provider" +MOVES="$RUN_DIR/player_moves.jsonl" +CHAT="$RUN_DIR/chat.jsonl" +CONFIG="$PROVIDER_DIR/codex-dm.toml" +PROMPT_FILE="$PROVIDER_DIR/dm-prompt.md" +STDOUT_LOG="$PROVIDER_DIR/codex-dm.stdout.jsonl" +STDERR_LOG="$PROVIDER_DIR/codex-dm.stderr.log" +LAST_MESSAGE="$PROVIDER_DIR/codex-dm.last.txt" +VIEWER_LOG="$RUN_DIR/viewer.log" +VIEWER_URL="http://127.0.0.1:$CLAWDND_PLAY_PORT/openworlds/" + +mkdir -p "$PROVIDER_DIR" +touch "$MOVES" "$CHAT" + +python3 - "$ROOT" "$RUN_DIR" "$CONFIG" <<'PY' +import json +import sys +from pathlib import Path + +root, state_dir, out = sys.argv[1:] +servers = [ + ( + "clawdnd-engine", + f"{root}/servers/engine", + "server.py", + {"CLAWDND_STATE_DIR": state_dir}, + ), + ( + "clawdnd-rules", + f"{root}/servers/rules", + "server.py", + {"CLAWDND_RULES_OFFLINE": "1"}, + ), + ( + "clawdnd-voice", + f"{root}/servers/voice", + "server.py", + {"CLAWDND_TTS_BACKEND": "null"}, + ), +] +lines = [] +for name, directory, script, env in servers: + lines.extend( + [ + f"[mcp_servers.{name}]", + 'command = "uv"', + "args = " + json.dumps(["run", "--directory", directory, "python", script]), + "env = " + "{" + + ", ".join(f"{key} = {json.dumps(value)}" for key, value in env.items()) + + "}", + "required = true", + 'default_tools_approval_mode = "approve"', + "", + ] + ) +Path(out).write_text("\n".join(lines), encoding="utf-8") +PY + +summary() { + python3 - "$MODE" "$ROOT" "$STATE_ROOT" "$CLAWDND_WORLD" "$CLAWDND_RUN_ID" "$CLAWDND_PLAY_PORT" "$CONFIG" "$MOVES" "$CHAT" "$VIEWER_URL" "${CLAWDND_PLAY_HERO:-}" <<'PY' +import json +import sys +from pathlib import Path + +mode, root, state_root, world, run_id, port, config, moves, chat, viewer_url, hero_raw = sys.argv[1:] +hero = {} +if hero_raw.strip(): + try: + parsed = json.loads(hero_raw) + if isinstance(parsed, dict): + hero = parsed + else: + hero = {"raw": hero_raw} + except json.JSONDecodeError: + hero = {"raw": hero_raw} +print(json.dumps({ + "ok": True, + "mode": mode, + "provider": "codex", + "role": "dm", + "repo": root, + "state_root": state_root, + "world": world, + "run_id": run_id, + "port": int(port), + "viewer_url": viewer_url, + "config": str(Path(config).resolve(strict=False)), + "moves": str(Path(moves).resolve(strict=False)), + "chat": str(Path(chat).resolve(strict=False)), + "hero": hero, +}, indent=2, sort_keys=True)) +PY +} + +echo "[codex-dm-provider] run=$CLAWDND_RUN_ID world=$CLAWDND_WORLD port=$CLAWDND_PLAY_PORT mode=$MODE" +echo "[codex-dm-provider] config=$CONFIG" +echo "[codex-dm-provider] moves=$MOVES" +echo "[codex-dm-provider] chat=$CHAT" + +if [ "$MODE" != "run" ]; then + summary + exit 0 +fi + +CODEX_MODEL="${CLAWDND_CODEX_MODEL:-}" +MODEL_ARGS=() +if [ -n "${CODEX_MODEL//[[:space:]]/}" ]; then + MODEL_ARGS=(--model "$CODEX_MODEL") +fi +export CLAWDND_STATE_DIR="$RUN_DIR" +export WORLDOS_STATE_DIR="$RUN_DIR" +export CLAWDND_RULES_OFFLINE=1 +export CLAWDND_TTS_BACKEND=null + +HERO_CAMP="" +HERO_PC_ID="" +HERO_PC_NAME="" +HERO_PC_RACE="" +HERO_PC_CLASS="" +if [ -n "${CLAWDND_PLAY_HERO:-}" ]; then + HERO_SEED_JSON="$(CLAWDND_STATE_DIR="$RUN_DIR" WORLDOS_STATE_DIR="$RUN_DIR" uv run --directory "$ROOT/servers/engine" python - "$CLAWDND_WORLD" "$CLAWDND_PLAY_HERO" <<'PY' +import json +import sys + +import server + +world, spec_raw = sys.argv[1], sys.argv[2] +try: + spec = json.loads(spec_raw) +except json.JSONDecodeError as exc: + sys.stderr.write(f"native hero spec is not valid JSON: {exc}\n") + sys.exit(1) +if not isinstance(spec, dict): + sys.stderr.write("native hero spec must be a JSON object\n") + sys.exit(1) +if not spec.get("canon"): + sys.stderr.write("Codex DM provider currently supports native roster canon hero specs only\n") + sys.exit(1) +canon_name = str(spec.get("name") or "").strip() +if not canon_name: + sys.stderr.write("native roster canon hero spec is missing name\n") + sys.exit(1) + +started = server.start_world(world) +camp = started.get("campaign_id") if isinstance(started, dict) else "" +if not camp: + sys.stderr.write("start_world did not return a campaign_id\n") + sys.exit(1) +server.start_session(camp, title=f"Codex DM: {canon_name}") +rec = server.load_canon_character(camp, canon_name, kind="player", add_to_party=True) +if not isinstance(rec, dict) or rec.get("error"): + sys.stderr.write("canon pickup failed: " + str((rec or {}).get("error") if isinstance(rec, dict) else rec) + "\n") + sys.exit(1) + +print(json.dumps({ + "campaign_id": camp, + "pc": { + "id": rec.get("id") or "", + "name": rec.get("name") or canon_name, + "race": str(rec.get("race") or ""), + "class": str(rec.get("class") or ""), + }, +})) +PY +)" || fail "native-selected hero pre-seed failed" + HERO_CAMP="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.campaign_id // ""')" + HERO_PC_ID="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.id // ""')" + HERO_PC_NAME="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.name // ""')" + HERO_PC_RACE="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.race // ""')" + HERO_PC_CLASS="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.class // ""')" + [ -n "$HERO_CAMP" ] || fail "native-selected hero pre-seed returned no campaign" + echo "[codex-dm-provider] seeded native-selected hero: $HERO_PC_NAME ($HERO_PC_RACE $HERO_PC_CLASS) in campaign $HERO_CAMP" +fi + +chatlog() { + python3 - "$CHAT" "$1" "$2" <<'PY' +import json +import sys + +path, role, text = sys.argv[1:] +with open(path, "a", encoding="utf-8") as handle: + handle.write(json.dumps({"role": role, "text": text}) + "\n") +PY +} + +codex_dm_turn() { + local prompt="$1" + printf '%s\n' "$prompt" > "$PROMPT_FILE" + : > "$LAST_MESSAGE" + local status=0 + codex exec \ + --ignore-user-config \ + --ignore-rules \ + --sandbox read-only \ + --json \ + ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \ + --cd "$ROOT" \ + --output-last-message "$LAST_MESSAGE" \ + -c "mcp_servers.clawdnd-engine.command=\"uv\"" \ + -c "mcp_servers.clawdnd-engine.args=[\"run\",\"--directory\",\"$ROOT/servers/engine\",\"python\",\"server.py\"]" \ + -c "mcp_servers.clawdnd-engine.env_vars=[\"CLAWDND_STATE_DIR\"]" \ + -c "mcp_servers.clawdnd-engine.required=true" \ + -c "mcp_servers.clawdnd-engine.default_tools_approval_mode=\"approve\"" \ + -c "mcp_servers.clawdnd-rules.command=\"uv\"" \ + -c "mcp_servers.clawdnd-rules.args=[\"run\",\"--directory\",\"$ROOT/servers/rules\",\"python\",\"server.py\"]" \ + -c "mcp_servers.clawdnd-rules.env_vars=[\"CLAWDND_RULES_OFFLINE\"]" \ + -c "mcp_servers.clawdnd-rules.required=true" \ + -c "mcp_servers.clawdnd-rules.default_tools_approval_mode=\"approve\"" \ + -c "mcp_servers.clawdnd-voice.command=\"uv\"" \ + -c "mcp_servers.clawdnd-voice.args=[\"run\",\"--directory\",\"$ROOT/servers/voice\",\"python\",\"server.py\"]" \ + -c "mcp_servers.clawdnd-voice.env_vars=[\"CLAWDND_TTS_BACKEND\"]" \ + -c "mcp_servers.clawdnd-voice.required=true" \ + -c "mcp_servers.clawdnd-voice.default_tools_approval_mode=\"approve\"" \ + - < "$PROMPT_FILE" \ + > >(tee -a "$STDOUT_LOG" >/dev/null) \ + 2> >(tee -a "$STDERR_LOG" >&2) || status=$? + [ "$status" -eq 0 ] || return "$status" + + local last + last="$(cat "$LAST_MESSAGE" 2>/dev/null || true)" + [ -n "${last//[[:space:]]/}" ] || return 3 + printf '%s' "$last" +} + +VPID_FILE="$RUN_DIR/.viewer.pid" +viewer_supervisor() { + while :; do + WORLDOS_STATE_DIR="$RUN_DIR" CLAWDND_STATE_DIR="$RUN_DIR" \ + WORLDOS_VIEWER_CHAT="$CHAT" CLAWDND_VIEWER_CHAT="$CHAT" \ + WORLDOS_PLAYER_MOVES="$MOVES" CLAWDND_PLAYER_MOVES="$MOVES" \ + python3 viewer/server.py "" "$CLAWDND_PLAY_PORT" >> "$VIEWER_LOG" 2>&1 & + local vp=$! + echo "$vp" > "$VPID_FILE" + wait "$vp" 2>/dev/null + sleep 1 + done +} +viewer_supervisor & SUP=$! +_cleanup() { + kill "$SUP" 2>/dev/null || true + [ -f "$VPID_FILE" ] && kill "$(cat "$VPID_FILE" 2>/dev/null)" 2>/dev/null || true +} +trap _cleanup EXIT +trap '_cleanup; exit 130' INT TERM + +echo "WorldOS Codex DM provider -> $VIEWER_URL" +echo " Save dir: $RUN_DIR" + +MCURSOR="$(wc -l < "$MOVES" 2>/dev/null | tr -d ' ')" +MCURSOR="${MCURSOR:-0}" + +if [ -n "$HERO_CAMP" ]; then + OPENING_PROMPT="$(cat </dev/null | tr -d ' ')" + total="${total:-0}" + if [ "$total" -gt "$MCURSOR" ]; then + new="$(tail -n +"$((MCURSOR + 1))" "$MOVES" 2>/dev/null)" + MCURSOR="$total" + PMSG="$(printf '%s' "$new" | jq -rs 'map("[\(.kind)] \(.text // .name // "")") | join(" ")' 2>/dev/null)" + [ -z "$PMSG" ] && continue + chatlog player "$PMSG" + if ! REPLY="$(codex_dm_turn "You are the Dungeon Master mid-session. Re-ground from the engine state first, then resolve this player move through the engine/rules tools and reply with 2nd-person player-facing narration. + +Player move: +$PMSG")"; then + fail "Codex DM move turn failed; see $STDERR_LOG" + fi + chatlog dm "$REPLY" + DM_TURNS=$((DM_TURNS + 1)) + else + sleep 2 + fi +done diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index 4e2ed159..d5592655 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -13,6 +13,7 @@ ROOT = Path(__file__).resolve().parents[3] SCRIPT = ROOT / "scripts" / "play_codex_actor.sh" +DM_SCRIPT = ROOT / "scripts" / "play_codex_dm.sh" def _env(tmp_path: Path, **overrides: str) -> dict[str, str]: @@ -44,6 +45,17 @@ def _run(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess: ) +def _run_dm(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess: + return subprocess.run( + ["/bin/bash", str(DM_SCRIPT), *args], + cwd=ROOT, + env=env, + capture_output=True, + check=False, + text=True, + ) + + def test_codex_wrapper_fails_closed_without_required_env(tmp_path): result = _run(["--dry-run"], {"PATH": os.environ.get("PATH", ""), "TMPDIR": str(tmp_path)}) @@ -78,6 +90,7 @@ def test_codex_wrapper_smoke_generates_player_facade_config_only(tmp_path): assert "[mcp_servers.clawdnd-player]" in config assert "player_server.py" in config assert "CLAWDND_PLAYER_MOVES" in config + assert 'default_tools_approval_mode = "approve"' in config assert "servers/engine/server.py" not in config assert "qa/" not in config @@ -93,3 +106,183 @@ def test_codex_wrapper_dry_run_uses_play_state_layout(tmp_path): summary = json.loads(result.stdout[result.stdout.index("{") :]) assert summary["config"].endswith("/layout-check/codex-provider/codex-player.toml") assert summary["moves"].endswith("/layout-check/player_moves.jsonl") + + +def test_codex_dm_wrapper_dry_run_generates_dm_contract(tmp_path): + result = _run_dm(["--dry-run"], _env(tmp_path, CLAWDND_RUN_ID="dm-layout")) + + assert result.returncode == 0, result.stdout + result.stderr + summary = json.loads(result.stdout[result.stdout.index("{") :]) + assert summary["ok"] is True + assert summary["mode"] == "dry-run" + assert summary["provider"] == "codex" + assert summary["role"] == "dm" + assert summary["viewer_url"].endswith(":8765/openworlds/") + assert summary["config"].endswith("/dm-layout/codex-provider/codex-dm.toml") + assert summary["moves"].endswith("/dm-layout/player_moves.jsonl") + assert summary["chat"].endswith("/dm-layout/chat.jsonl") + + config = Path(summary["config"]).read_text(encoding="utf-8") + assert "[mcp_servers.clawdnd-engine]" in config + assert "[mcp_servers.clawdnd-rules]" in config + assert "[mcp_servers.clawdnd-voice]" in config + assert "/servers/engine" in config + assert "/servers/rules" in config + assert "/servers/voice" in config + assert '"python"' in config + assert '"server.py"' in config + assert config.count('default_tools_approval_mode = "approve"') == 3 + assert "player_server.py" not in config + assert "CLAWDND_STATE_DIR" in config + + assert Path(summary["moves"]).exists() + assert Path(summary["chat"]).exists() + + +def test_codex_dm_wrapper_dry_run_surfaces_native_selected_hero(tmp_path): + hero = json.dumps({"canon": True, "name": "Abby"}) + result = _run_dm(["--dry-run"], _env(tmp_path, CLAWDND_PLAY_HERO=hero)) + + assert result.returncode == 0, result.stdout + result.stderr + summary = json.loads(result.stdout[result.stdout.index("{") :]) + assert summary["hero"] == {"canon": True, "name": "Abby"} + + +def test_codex_dm_wrapper_honors_native_selected_hero(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "CLAWDND_PLAY_HERO" in source + assert 'load_canon_character(camp, canon_name, kind="player", add_to_party=True)' in source + assert "Native-selected canon hero already seated" in source + + +def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_codex = bin_dir / "codex" + fake_codex.write_text( + """#!/usr/bin/env bash +set -euo pipefail +last="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message) + last="$2" + shift 2 + ;; + --model) + echo "unexpected model arg" >&2 + exit 7 + ;; + *) + shift + ;; + esac +done +cat >/dev/null +printf 'Opening narration from fake Codex.' > "$last" +printf '{"type":"result","result":"Opening narration from fake Codex."}\n' +""", + encoding="utf-8", + ) + fake_codex.chmod(0o755) + env = _env( + tmp_path, + PATH=f"{bin_dir}:{os.environ.get('PATH', '')}", + CLAWDND_RUN_ID="fake-codex-run", + CLAWDND_PLAY_PORT="8797", + CLAWDND_PLAY_HERO=json.dumps({"canon": True, "name": "Abby"}), + ) + + result = _run_dm([], env) + + assert result.returncode == 0, result.stdout + result.stderr + chat = tmp_path / "fake-codex-run" / "chat.jsonl" + assert "Opening narration from fake Codex." in chat.read_text(encoding="utf-8") + + +def test_codex_dm_wrapper_processes_moves_submitted_during_opening(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_codex = bin_dir / "codex" + fake_codex.write_text( + """#!/usr/bin/env bash +set -euo pipefail +last="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message) + last="$2" + shift 2 + ;; + *) + shift + ;; + esac +done +cat >/dev/null +marker="$CLAWDND_STATE_DIR/.fake-opening-seen" +if [ ! -f "$marker" ]; then + touch "$marker" + printf '{"role":"player","kind":"do","text":"queued during opening"}\\n' >> "$CLAWDND_STATE_DIR/player_moves.jsonl" + printf 'Opening narration from fake Codex.' > "$last" + printf '{"type":"result","result":"Opening narration from fake Codex."}\\n' +else + printf 'Second turn response from fake Codex.' > "$last" + printf '{"type":"result","result":"Second turn response from fake Codex."}\\n' +fi +""", + encoding="utf-8", + ) + fake_codex.chmod(0o755) + env = _env( + tmp_path, + PATH=f"{bin_dir}:{os.environ.get('PATH', '')}", + CLAWDND_RUN_ID="queued-opening-move", + CLAWDND_PLAY_PORT="8798", + CLAWDND_PLAY_MAX_TURNS="2", + ) + + result = subprocess.run( + ["/bin/bash", str(DM_SCRIPT)], + cwd=ROOT, + env=env, + capture_output=True, + check=False, + text=True, + timeout=20, + ) + + assert result.returncode == 0, result.stdout + result.stderr + chat = (tmp_path / "queued-opening-move" / "chat.jsonl").read_text(encoding="utf-8") + assert "Opening narration from fake Codex." in chat + assert "[do] queued during opening" in chat + assert "Second turn response from fake Codex." in chat + + +def test_native_codex_provider_defaults_to_dm_wrapper(): + source = (ROOT / "macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift").read_text( + encoding="utf-8" + ) + + assert "scripts/play_codex_dm.sh" in source + assert "scripts/play_codex_actor.sh" in source + assert source.index("scripts/play_codex_dm.sh") < source.index("scripts/play_codex_actor.sh") + + +def test_native_codex_provider_passes_selected_hero_to_wrapper(): + source = (ROOT / "macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift").read_text( + encoding="utf-8" + ) + + assert 'environment["CLAWDND_PLAY_HERO"] = trimmedHero' in source + assert "hero: hero," in source + + +def test_codex_wrappers_match_current_cli_flags(): + for script in (SCRIPT, DM_SCRIPT): + source = script.read_text(encoding="utf-8") + assert "--ask-for-approval" not in source + assert "--sandbox read-only" in source + assert 'default_tools_approval_mode=\\"approve\\"' in source + assert "gpt-5.1-codex-max" not in source diff --git a/viewer/openworlds/screen-create.jsx b/viewer/openworlds/screen-create.jsx index 8467f3ad..0a510f66 100644 --- a/viewer/openworlds/screen-create.jsx +++ b/viewer/openworlds/screen-create.jsx @@ -150,11 +150,11 @@ function ScreenCreate({ onNavigate, state, setState, preferredProvider = "" }) { if (preferredProvider) payload.provider = preferredProvider; const reply = await window.OpenWorldsNative.request("startProviderSession", payload); // Drive the reload to the live viewer from JS using the URL the bridge returns (same as - // screen-launcher) — the live viewer boots fresh and app.jsx auto-routes into the table - // once the provider is running. + // screen-launcher) — replace the stale standalone viewer history entry so Back cannot + // return the player to a read-only table after the provider is attached. const liveUrl = reply && (reply.url || reply.viewer?.openWorldsURL); if (liveUrl) { - window.location.assign(liveUrl); + window.location.replace(liveUrl); return; } window.OpenWorldsBuilding?.clear?.(); diff --git a/viewer/openworlds/screen-launcher.jsx b/viewer/openworlds/screen-launcher.jsx index f4359948..d5c4a03b 100644 --- a/viewer/openworlds/screen-launcher.jsx +++ b/viewer/openworlds/screen-launcher.jsx @@ -58,11 +58,11 @@ function ScreenLauncher({ onNavigate, state, setState, preferredProvider = "" }) const reply = await window.OpenWorldsNative.request("startProviderSession", payload); // Drive the reload to the live, sink-wired viewer from JS using the URL the bridge // returns — don't rely on the native WebView re-binding its own state across the async - // hop. The live viewer boots fresh and app.jsx auto-routes into the table once the - // provider is running. (location.assign re-runs the native bridge user-script there.) + // hop. Replace history instead of appending it: the previous standalone viewer is stale + // once a provider is attached, and Back must not strand the player on a read-only table. const liveUrl = reply && (reply.url || reply.viewer?.openWorldsURL); if (liveUrl) { - window.location.assign(liveUrl); + window.location.replace(liveUrl); return; } window.OpenWorldsBuilding?.clear?.(); diff --git a/viewer/openworlds/screen-roster.jsx b/viewer/openworlds/screen-roster.jsx index f3f6b8ca..11fb5508 100644 --- a/viewer/openworlds/screen-roster.jsx +++ b/viewer/openworlds/screen-roster.jsx @@ -140,6 +140,8 @@ function ScreenRoster({ onNavigate, state, setState, preferredProvider = "" }) { const campaignId = campaigns.some((c) => c.id === state?.activeCampaign) ? state.activeCampaign : (campaigns[0]?.id || ""); + const activeCampaign = campaigns.find((c) => c.id === campaignId) || campaigns[0] || {}; + const rosterCampaignId = activeCampaign.campaign_id || campaignId; const hasBridge = Boolean(window.OpenWorldsNative?.hasBridge?.()); // #326: an already-playable session (live + resumable) the browser player can enter directly, // mirroring the launcher. Without the desktop bridge a NEW hero bind can't mint a DM session, @@ -169,7 +171,9 @@ function ScreenRoster({ onNavigate, state, setState, preferredProvider = "" }) { setLoading(true); try { const params = new URLSearchParams(); - if (campaignId) params.set("campaign", campaignId); + if (rosterCampaignId) params.set("campaign", rosterCampaignId); + if (activeCampaign.source) params.set("source", activeCampaign.source); + if (activeCampaign.runId) params.set("run", activeCampaign.runId); if (race) params.set("race", race); if (klass) params.set("class", klass); if (level) params.set("level", level); @@ -196,7 +200,7 @@ function ScreenRoster({ onNavigate, state, setState, preferredProvider = "" }) { } finally { if (!isCancelled()) setLoading(false); } - }, [campaignId, race, klass, level]); + }, [rosterCampaignId, activeCampaign.source, activeCampaign.runId, race, klass, level]); React.useEffect(() => { let cancelled = false; @@ -245,7 +249,7 @@ function ScreenRoster({ onNavigate, state, setState, preferredProvider = "" }) { const reply = await window.OpenWorldsNative.request("startProviderSession", payload); const liveUrl = reply && (reply.url || reply.viewer?.openWorldsURL); if (liveUrl) { - window.location.assign(liveUrl); + window.location.replace(liveUrl); return; } setSummoningName(""); diff --git a/viewer/server.py b/viewer/server.py index 28ab507e..605473ef 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -765,6 +765,7 @@ def build_roster_response( char_class: str = "", level: str = "", limit: int = 120, + world_id: str = "", ) -> dict: """GET /roster-surface read model — the canon-NPC PICKER ("reverse character creator"). @@ -777,7 +778,9 @@ def build_roster_response( the native startProviderSession bridge / load_canon_character, never here. Mirrors build_bestiary_response: a graceful empty payload when the engine can't be imported.""" engine = _load_engine_server() - world_id = _roster_world_for_campaign(campaign_id) + world_id = world_id.strip() if isinstance(world_id, str) else "" + if not world_id: + world_id = _roster_world_for_campaign(campaign_id) if engine is None or not hasattr(engine, "content_mod") or not hasattr(engine.content_mod, "roster_surface"): detail = _ENGINE_IMPORT_ERROR or "engine roster projection is unavailable" return { @@ -6181,6 +6184,7 @@ def do_GET(self) -> None: # noqa: N802 # bridge / load_canon_character, never here. qs = parse_qs(parsed.query) cid = (qs.get("campaign") or [""])[0] or self._view_campaign(qs) + catalog_ref = _session_surface_catalog_ref(qs) race = (qs.get("race") or [""])[0] char_class = (qs.get("class") or qs.get("char_class") or [""])[0] level = (qs.get("level") or [""])[0] @@ -6191,6 +6195,11 @@ def do_GET(self) -> None: # noqa: N802 except (TypeError, ValueError): limit = 120 limit = max(1, min(500, limit)) + if catalog_ref is not None: + cid, raw_snap, _campaign_dir, _root_is_current = catalog_ref + world_id = raw_snap.get("world_id") if isinstance(raw_snap, dict) else "" + self._json(build_roster_response(cid, race, char_class, level, limit, world_id=world_id or "")) + return self._json(build_roster_response(cid, race, char_class, level, limit)) elif route in ("/monitor", "/monitor.html"): # The MULTI-CAMPAIGN monitor: one live page showing EVERY campaign across the play diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 3120b3c3..b94af3e3 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -153,6 +153,18 @@ def test_openworlds_render_bridge_fetches_image_endpoint(self): self.assertIn("Img scope=", rel_src) self.assertIn("portrait-", rel_src) + def test_provider_launch_replaces_stale_standalone_viewer_history(self): + # Once a native provider attaches, the previous standalone viewer is stale/read-only. + # Replacing history keeps Back/navigation drift from disconnecting the player from /move. + for rel in ( + "screen-launcher.jsx", + "screen-create.jsx", + "screen-roster.jsx", + ): + source = (server._OPENWORLDS_DIR / rel).read_text(encoding="utf-8") + self.assertIn("window.location.replace(liveUrl)", source, rel) + self.assertNotIn("window.location.assign(liveUrl)", source, rel) + def test_character_screen_window_exports_are_defined(self): status, ctype, body = self._get("/openworlds/screen-character.jsx") @@ -557,6 +569,15 @@ def test_native_start_surfaces_use_app_selected_provider(self): self.assertIn("if (provider) payload.provider = provider;", settings) self.assertNotIn('provider: prefs.selectedProvider || app.selectedProvider || "claude"', settings) + def test_roster_screen_uses_catalog_campaign_scope(self): + source = (Path(__file__).resolve().parents[1] / "openworlds" / "screen-roster.jsx").read_text(encoding="utf-8") + + self.assertIn("activeCampaign.campaign_id || campaignId", source) + self.assertIn('params.set("campaign", rosterCampaignId);', source) + self.assertIn('params.set("source", activeCampaign.source);', source) + self.assertIn('params.set("run", activeCampaign.runId);', source) + self.assertNotIn('params.set("campaign", campaignId);', source) + def test_monitor_play_campaign_links_openworlds_not_legacy_dashboard(self): source = (Path(__file__).resolve().parents[1] / "monitor.html").read_text(encoding="utf-8") diff --git a/viewer/tests/test_roster_surface.py b/viewer/tests/test_roster_surface.py index d38f674b..78e8882e 100644 --- a/viewer/tests/test_roster_surface.py +++ b/viewer/tests/test_roster_surface.py @@ -53,6 +53,7 @@ def setUp(self): self._tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) self._old_state = os.environ.get("CLAWDND_STATE_DIR") os.environ["CLAWDND_STATE_DIR"] = str(self._tmp) + self._old_here = server._HERE _QuietHandler.campaign_id = "" _QuietHandler.transcript_path = "" _QuietHandler.chat_path = "" @@ -70,6 +71,7 @@ def tearDown(self): os.environ.pop("CLAWDND_STATE_DIR", None) else: os.environ["CLAWDND_STATE_DIR"] = self._old_state + server._HERE = self._old_here def _get_json(self, path: str) -> tuple[int, dict]: conn = http.client.HTTPConnection(self._host, self._port, timeout=10) @@ -174,6 +176,22 @@ def test_level_filter_narrows_to_that_level(self): self.assertTrue(chars) self.assertTrue(all(c.get("level") == "5" for c in chars)) + def test_catalog_run_uses_snapshot_world_scope(self): + repo_root = self._tmp / "repo" + (repo_root / "viewer").mkdir(parents=True) + server._HERE = repo_root / "viewer" + qa_campaign = repo_root / "qa" / "state" / "wave3-red" / "campaigns" / "camp_qa" + qa_campaign.mkdir(parents=True) + (qa_campaign / "snapshot.json").write_text( + json.dumps({"id": "camp_qa", "world_id": "sundered-reach"}), + encoding="utf-8", + ) + + status, surface = self._get_json("/roster-surface?source=qa&run=wave3-red&campaign=camp_qa&limit=1") + + self.assertEqual(status, 200) + self.assertEqual(surface.get("world_id"), "sundered-reach") + # ── facets for the filter chips ────────────────────────────────────────────── def test_facets_present_and_frequency_ordered(self): From 7ce7ffdfe0221f46743c64219d51a8d3e71d85c5 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 01:53:19 +0700 Subject: [PATCH 02/10] Address Codex provider review comments --- WorldOS-GUI-RUNBOOK.md | 2 +- WorldOS-OPERATING-GOAL.md | 10 +++---- WorldOS-RUNBOOK.md | 2 +- .../Services/ProviderAdapters.swift | 29 ++++++++++--------- .../tests/test_codex_provider_wrapper.py | 24 ++++++++------- viewer/server.py | 15 ++++++---- viewer/tests/test_roster_surface.py | 16 ++++++++++ 7 files changed, 61 insertions(+), 37 deletions(-) diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index 559fb6fe..45d794ef 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -32,7 +32,7 @@ not loaded app status yet, it omits `provider` and lets Swift's `selectedProviderRaw` setting decide. - The Codex path now has two wrappers: `scripts/play_codex_dm.sh` for the selected provider's DM loop, and `scripts/play_codex_actor.sh` for constrained player/companion actor work. Do not swap them. -- Do not treat the wrapper as release proof by itself. The 2026-06-01 local built-app proof shows +- Do not treat the wrapper as release proof by itself. The 2026-06-01T01:24:10+07:00 local built-app proof shows it can mint the world, write DM narration, submit one player move, and leave `/session-surface` with `can_act:true` for the live/current campaign. Release still requires a short built-app playtest plus the full non-partial RRI gate. diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index ad46e73d..6a72c1ec 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,9 +5,9 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-06-01 local Codex-DM built-app first-turn proof + AS OF: 2026-06-01T01:40:00+07:00 local Codex-DM built-app first-turn proof MAIN BASELINE: - 6e03da4 (PRs #470, #471, #472, #473 merged; verified in /Users/lume/ClawDnD-val on 2026-06-01). + 6e03da4 (PRs #470, #471, #472, #473 merged; verified in /Users/lume/ClawDnD-val on 2026-06-01T01:40:00+07:00). Re-verify current `origin/main` before acting. CANONICAL: /Users/lume/ClawDnD-val is now the synced local app/private-art checkout and the default place to build/run/test the Mac app. Keep GUI/runtime tests on this @@ -25,7 +25,7 @@ personas failed around port/backend harness setup; behavioral/UI/palette/image evidence was not a valid five-persona release verdict. LAST BUILT-APP PLAY PROOF: - 2026-06-01 local `dist/WorldOS.app` proof with private BG art, Codex DM + 2026-06-01T01:24:10+07:00 local `dist/WorldOS.app` proof with private BG art, Codex DM provider, visible narration, enabled player actions, submitted `/move`, and post-move DM response. Evidence lives under /Volumes/LEXAR/Codex/worldos-built-app-proof/ @@ -176,14 +176,14 @@ verifier; can revert the goal to "fix" anytime. --- -## 9. CURRENT STATUS (2026-06-01 — first-turn proof achieved, NOT a release verdict) +## 9. CURRENT STATUS (2026-06-01T01:40:00+07:00 — first-turn proof achieved, NOT a release verdict) - Repo truth stabilization merged in PR #465, UX-first doc sync merged in PR #468, and first-minute click/title chrome proof merged in PR #470. Local/Lexar/support-VM routing merged in PR #471. Native OpenWorlds starts now honor the macOS app's selected provider instead of forcing Claude from the web surface in PR #472. PR #473 synced the takeover docs after provider selection. The local app/private-art checkout `/Users/lume/ClawDnD-val` is synced to - `6e03da4 == origin/main` as of 2026-06-01. + `6e03da4 == origin/main` as of 2026-06-01T01:40:00+07:00. - The stale local pre-sync artifacts were preserved before the fast-forward at `/Volumes/LEXAR/Codex/worldos-local-checkout-snapshot-20260531T223923` and in `stash@{0}` (`pre-sync local takeover docs 2026-05-31`). Treat those as evidence, not current release truth. diff --git a/WorldOS-RUNBOOK.md b/WorldOS-RUNBOOK.md index a34847db..47d0ba10 100644 --- a/WorldOS-RUNBOOK.md +++ b/WorldOS-RUNBOOK.md @@ -25,7 +25,7 @@ > If an operator hands you local session notes or decision records, treat them as > private working artifacts unless they are intentionally promoted into tracked docs. > -> Last updated: 2026-06-01 (Codex-DM built-app first-turn proof + TCC test-contamination note; release notes below are historical context). +> Last updated: 2026-06-01T01:40:00+07:00 (Codex-DM built-app first-turn proof + TCC test-contamination note; release notes below are historical context). > > **Graphics & game-types roadmap (canonical):** the long-term plan for the kinds of games > WorldOS can produce (GT0 narrative dashboard → GT1 SNES pixel → GT2 Pillars/BG isometric) diff --git a/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift b/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift index b34f377f..85c6e7ee 100644 --- a/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift +++ b/macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift @@ -108,13 +108,15 @@ struct CodexProvider: ProviderAdapter { ) } - guard FileManager.default.fileExists(atPath: wrapper.path) else { - return ProviderStatus( - kind: kind, - availability: .error, - detail: "Codex CLI found, but scripts/play_codex_dm.sh is missing from this checkout.", - detectedPath: cli - ) + if configuredCommand.isEmpty { + guard FileManager.default.fileExists(atPath: wrapper.path) else { + return ProviderStatus( + kind: kind, + availability: .error, + detail: "Codex CLI found, but scripts/play_codex_dm.sh is missing from this checkout.", + detectedPath: cli + ) + } } return ProviderStatus( @@ -123,7 +125,7 @@ struct CodexProvider: ProviderAdapter { detail: configuredCommand.isEmpty ? "Ready. Launches the checked-in Codex DM wrapper with the WorldOS provider environment. Actor helper: \(actorHelper.path)." : "Ready. Launches your configured Codex command with the WorldOS provider environment.", - detectedPath: configuredCommand.isEmpty ? wrapper.path : cli + detectedPath: configuredCommand.isEmpty ? wrapper.path : configuredCommand ) } @@ -140,12 +142,13 @@ struct CodexProvider: ProviderAdapter { throw ProviderError.missingDependency("Codex CLI is missing. Install codex before starting a Codex provider session.") } - let wrapper = repoPath.appendingPathComponent("scripts/play_codex_dm.sh") - guard FileManager.default.fileExists(atPath: wrapper.path) else { - throw ProviderError.configuration("Codex provider wrapper is missing: scripts/play_codex_dm.sh") - } - let configuredCommand = preferences.codexCommand.trimmingCharacters(in: .whitespacesAndNewlines) + if configuredCommand.isEmpty { + let wrapper = repoPath.appendingPathComponent("scripts/play_codex_dm.sh") + guard FileManager.default.fileExists(atPath: wrapper.path) else { + throw ProviderError.configuration("Codex provider wrapper is missing: scripts/play_codex_dm.sh") + } + } let command = configuredCommand.isEmpty ? defaultCodexCommand : configuredCommand return ProviderLaunchRequest( name: "Codex game", diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index d5592655..fd3067d1 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -45,7 +45,7 @@ def _run(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess: ) -def _run_dm(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess: +def _run_dm(args: list[str], env: dict[str, str], timeout: float | None = None) -> subprocess.CompletedProcess: return subprocess.run( ["/bin/bash", str(DM_SCRIPT), *args], cwd=ROOT, @@ -53,6 +53,7 @@ def _run_dm(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess capture_output=True, check=False, text=True, + timeout=timeout, ) @@ -194,7 +195,7 @@ def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): CLAWDND_PLAY_HERO=json.dumps({"canon": True, "name": "Abby"}), ) - result = _run_dm([], env) + result = _run_dm([], env, timeout=20) assert result.returncode == 0, result.stdout + result.stderr chat = tmp_path / "fake-codex-run" / "chat.jsonl" @@ -243,15 +244,7 @@ def test_codex_dm_wrapper_processes_moves_submitted_during_opening(tmp_path): CLAWDND_PLAY_MAX_TURNS="2", ) - result = subprocess.run( - ["/bin/bash", str(DM_SCRIPT)], - cwd=ROOT, - env=env, - capture_output=True, - check=False, - text=True, - timeout=20, - ) + result = _run_dm([], env, timeout=20) assert result.returncode == 0, result.stdout + result.stderr chat = (tmp_path / "queued-opening-move" / "chat.jsonl").read_text(encoding="utf-8") @@ -279,6 +272,15 @@ def test_native_codex_provider_passes_selected_hero_to_wrapper(): assert "hero: hero," in source +def test_native_codex_provider_custom_command_does_not_require_default_wrapper(): + source = (ROOT / "macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift").read_text( + encoding="utf-8" + ) + + assert "if configuredCommand.isEmpty" in source + assert "detectedPath: configuredCommand.isEmpty ? wrapper.path : configuredCommand" in source + + def test_codex_wrappers_match_current_cli_flags(): for script in (SCRIPT, DM_SCRIPT): source = script.read_text(encoding="utf-8") diff --git a/viewer/server.py b/viewer/server.py index 605473ef..72f63bd6 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -765,7 +765,7 @@ def build_roster_response( char_class: str = "", level: str = "", limit: int = 120, - world_id: str = "", + world_id: Optional[str] = "", ) -> dict: """GET /roster-surface read model — the canon-NPC PICKER ("reverse character creator"). @@ -778,9 +778,12 @@ def build_roster_response( the native startProviderSession bridge / load_canon_character, never here. Mirrors build_bestiary_response: a graceful empty payload when the engine can't be imported.""" engine = _load_engine_server() - world_id = world_id.strip() if isinstance(world_id, str) else "" - if not world_id: - world_id = _roster_world_for_campaign(campaign_id) + if world_id is None: + world_id = "" + else: + world_id = world_id.strip() if isinstance(world_id, str) else "" + if not world_id: + world_id = _roster_world_for_campaign(campaign_id) if engine is None or not hasattr(engine, "content_mod") or not hasattr(engine.content_mod, "roster_surface"): detail = _ENGINE_IMPORT_ERROR or "engine roster projection is unavailable" return { @@ -6197,8 +6200,8 @@ def do_GET(self) -> None: # noqa: N802 limit = max(1, min(500, limit)) if catalog_ref is not None: cid, raw_snap, _campaign_dir, _root_is_current = catalog_ref - world_id = raw_snap.get("world_id") if isinstance(raw_snap, dict) else "" - self._json(build_roster_response(cid, race, char_class, level, limit, world_id=world_id or "")) + world_id = raw_snap.get("world_id") if isinstance(raw_snap, dict) else None + self._json(build_roster_response(cid, race, char_class, level, limit, world_id=world_id)) return self._json(build_roster_response(cid, race, char_class, level, limit)) elif route in ("/monitor", "/monitor.html"): diff --git a/viewer/tests/test_roster_surface.py b/viewer/tests/test_roster_surface.py index 78e8882e..78b28943 100644 --- a/viewer/tests/test_roster_surface.py +++ b/viewer/tests/test_roster_surface.py @@ -192,6 +192,22 @@ def test_catalog_run_uses_snapshot_world_scope(self): self.assertEqual(status, 200) self.assertEqual(surface.get("world_id"), "sundered-reach") + def test_catalog_run_without_world_id_does_not_fall_back_to_default_world(self): + repo_root = self._tmp / "repo" + (repo_root / "viewer").mkdir(parents=True) + server._HERE = repo_root / "viewer" + qa_campaign = repo_root / "qa" / "state" / "missing-world" / "campaigns" / "camp_qa" + qa_campaign.mkdir(parents=True) + (qa_campaign / "snapshot.json").write_text( + json.dumps({"id": "camp_qa"}), + encoding="utf-8", + ) + + status, surface = self._get_json("/roster-surface?source=qa&run=missing-world&campaign=camp_qa&limit=1") + + self.assertEqual(status, 200) + self.assertEqual(surface.get("world_id"), "") + # ── facets for the filter chips ────────────────────────────────────────────── def test_facets_present_and_frequency_ordered(self): From c3dfee6fcaff3a898562b2771b385f322dbd424d Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 02:04:39 +0700 Subject: [PATCH 03/10] Fix Codex provider review cleanup paths --- .../tests/test_codex_provider_wrapper.py | 34 +++++++++++++++---- viewer/server.py | 7 +++- viewer/tests/test_roster_surface.py | 16 +++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index fd3067d1..8b7e25f1 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -7,6 +7,7 @@ import json import os +import signal import subprocess from pathlib import Path @@ -45,16 +46,37 @@ def _run(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess: ) -def _run_dm(args: list[str], env: dict[str, str], timeout: float | None = None) -> subprocess.CompletedProcess: - return subprocess.run( - ["/bin/bash", str(DM_SCRIPT), *args], +def _run_dm( + args: list[str], + env: dict[str, str], + timeout: float | None = None, +) -> subprocess.CompletedProcess: + command = ["/bin/bash", str(DM_SCRIPT), *args] + proc = subprocess.Popen( + command, cwd=ROOT, env=env, - capture_output=True, - check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, text=True, - timeout=timeout, ) + try: + stdout, stderr = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + stdout, stderr = proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + stdout, stderr = proc.communicate() + return subprocess.CompletedProcess(command, proc.returncode, stdout, stderr) def test_codex_wrapper_fails_closed_without_required_env(tmp_path): diff --git a/viewer/server.py b/viewer/server.py index 72f63bd6..debf47e3 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -6200,7 +6200,12 @@ def do_GET(self) -> None: # noqa: N802 limit = max(1, min(500, limit)) if catalog_ref is not None: cid, raw_snap, _campaign_dir, _root_is_current = catalog_ref - world_id = raw_snap.get("world_id") if isinstance(raw_snap, dict) else None + raw_world_id = raw_snap.get("world_id") if isinstance(raw_snap, dict) else None + world_id = ( + raw_world_id.strip() + if isinstance(raw_world_id, str) and raw_world_id.strip() + else None + ) self._json(build_roster_response(cid, race, char_class, level, limit, world_id=world_id)) return self._json(build_roster_response(cid, race, char_class, level, limit)) diff --git a/viewer/tests/test_roster_surface.py b/viewer/tests/test_roster_surface.py index 78b28943..9539e5d6 100644 --- a/viewer/tests/test_roster_surface.py +++ b/viewer/tests/test_roster_surface.py @@ -208,6 +208,22 @@ def test_catalog_run_without_world_id_does_not_fall_back_to_default_world(self): self.assertEqual(status, 200) self.assertEqual(surface.get("world_id"), "") + def test_catalog_run_with_empty_world_id_does_not_fall_back_to_default_world(self): + repo_root = self._tmp / "repo" + (repo_root / "viewer").mkdir(parents=True) + server._HERE = repo_root / "viewer" + qa_campaign = repo_root / "qa" / "state" / "empty-world" / "campaigns" / "camp_qa" + qa_campaign.mkdir(parents=True) + (qa_campaign / "snapshot.json").write_text( + json.dumps({"id": "camp_qa", "world_id": ""}), + encoding="utf-8", + ) + + status, surface = self._get_json("/roster-surface?source=qa&run=empty-world&campaign=camp_qa&limit=1") + + self.assertEqual(status, 200) + self.assertEqual(surface.get("world_id"), "") + # ── facets for the filter chips ────────────────────────────────────────────── def test_facets_present_and_frequency_ordered(self): From 286496f2ed6624ded0a11c3c618172df843301d7 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 02:46:46 +0700 Subject: [PATCH 04/10] Document built-app Codex playtest proof --- AGENTS.md | 39 ++++++++++++++++++ WorldOS-GUI-RUNBOOK.md | 17 ++++++-- WorldOS-OPERATING-GOAL.md | 49 ++++++++++++++-------- qa/SCORECARD.md | 9 +++++ qa/test_macos_app_static.py | 13 ++++++ qa/ui_playtest_app.sh | 81 ++++++++++++++++++++++++++++--------- 6 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..913be649 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# WorldOS Agent Instructions + +## Codex Desktop Local-Resource Policy + +- Treat `/Users/lume/ClawDnD-val` as the canonical local Mac app/private-art checkout for WorldOS GUI and native-app testing. +- Use `/Volumes/LEXAR/Codex` for Codex artifacts, scratch files, screenshots, reports, and downloaded CI/VM artifacts. +- Use same-disk local worktrees for GUI/native-app edits that must launch against private art. Lexar worktrees are fine for docs, backend-only, and non-GUI slices that do not launch the app against private art. +- Before running install, build, or test commands, verify `pwd`. If a GUI/native app run is not in `/Users/lume/ClawDnD-val` or a same-disk worktree with `WORLDOS_ART_REPO_ROOT=/Users/lume/ClawDnD-val`, explain why. +- Prefer GitHub Actions or the 32GB support VM for heavyweight validation, full suites, matrix tests, long integration tests, and persona sweeps. +- Run local tests only for fast feedback, local-only reproduction, validating unpushed edits, or Mac-only `.app` proof. Use the narrowest focused command first. +- Do not launch multiple heavyweight local suites or persona sweeps in parallel on this Mac. +- If local test work causes memory pressure, stop it, report the command/path, and switch to a narrower check, GitHub CI, or the support VM. + +## WorldOS Takeover Truth + +- Read `WorldOS-OPERATING-GOAL.md` first, then `WorldOS-GUI-RUNBOOK.md`, `WorldOS-RUNBOOK.md`, and `qa/SCORECARD.md`. +- The product is the launchable, playable `dist/WorldOS.app`. Wrapper/config/test-only progress does not count as product progress unless it directly unlocks built-app gameplay evidence. +- Engine remains sole writer of campaign state. GUI/native app remains a thin reader plus `/move` intent submitter. +- Built-app proof must include visible narration, private art, an active player, enabled actions, accepted `/move`, and `/session-surface` showing the live campaign as actionable. +- The current `qa/RRI.json` from `f5500ac` is partial/harness-contaminated evidence, not a release verdict. +- Release evidence requires the RRI contract in `qa/release_readiness.py`: expected/completed/missing personas, disk-backed scores, behavior/UI/image/palette evidence, same build SHA, and non-partial status. + +## Support VM + +- Target VM: owner-provided 32GB support VM, `support-vm-1`. +- Connection/auth details are operator-only and should stay outside tracked repo docs. +- Use the support VM for heavy backend/persona sweeps only after Codex CLI credentials/config are intentionally installed and verified there. +- VM preflight must record VM identity, repo checkout path, branch/SHA, Codex CLI version, auth/profile status, `uv`, Node/npm/Playwright availability, private-art status or explicit backend-only/no-art classification, env vars, budget/concurrency cap, teardown commands, and artifact return path under `/Volumes/LEXAR/Codex`. +- The VM cannot prove Mac-only surfaces. `WorldOS.app` build/launch, native #356, and built-app UI play evidence stay on this Mac or macOS CI. +- VM artifacts can feed RRI only when `run.json`, `score.json`, `session_surface.final.json`, network/image evidence, palette-live evidence, and build SHA are explicit. Otherwise the result remains partial/harness-contaminated. + +## GitHub And Reviews + +- Use branch prefix `codex/` for new branches unless instructed otherwise. +- Keep PRs draft until the evidence is honest enough for review. +- If a PR is part of the work, do not end while required checks, review-bot status, or current actionable review threads are unresolved unless the user explicitly asks to pause. +- Keep up with CodeRabbit and GitHub review threads. Verify each comment against the code, fix valid issues, and rerun focused validation before pushing. +- Treat generic warning-only bot suggestions as non-blocking unless they identify a real defect or the repository enforces them. + diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index 45d794ef..5a7e7b6a 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -32,10 +32,11 @@ not loaded app status yet, it omits `provider` and lets Swift's `selectedProviderRaw` setting decide. - The Codex path now has two wrappers: `scripts/play_codex_dm.sh` for the selected provider's DM loop, and `scripts/play_codex_actor.sh` for constrained player/companion actor work. Do not swap them. -- Do not treat the wrapper as release proof by itself. The 2026-06-01T01:24:10+07:00 local built-app proof shows - it can mint the world, write DM narration, submit one player move, and leave `/session-surface` - with `can_act:true` for the live/current campaign. Release still requires a short built-app playtest - plus the full non-partial RRI gate. +- Do not treat the wrapper as release proof by itself. The 2026-06-01T02:38:00+07:00 local built-app playtest + (`/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/`) shows the Codex-DM + path can mint a live native session, load private BG art, seat Arka, show narration, expose five enabled + actions, accept two `/move`s, produce two DM responses, and leave `/session-surface` actionable. Release + still requires the full non-partial RRI gate. ## Stand up the iteration surface (8799, playable, from canonical) ```bash @@ -125,6 +126,14 @@ release truth still requires `qa/ui_playtest_app.sh` Part A+B and the full RRI s - Use it for heavy backend/persona release sweeps and parallel QA once configured. - Do **not** use it as proof for Mac-only surfaces: `WorldOS.app` build/launch, native #356, and built-app UI play evidence stay on this Mac or macOS CI. +- VM preflight before any RRI sweep: record VM identity, repo checkout path, branch/SHA, Codex CLI version, + auth/profile status, `uv`, Node/npm/Playwright availability, private-art availability or explicit + backend-only/no-art classification, env vars, budget/concurrency cap, teardown commands, and the artifact + return path under `/Volumes/LEXAR/Codex`. +- RRI rollup rule: Mac/local evidence supplies native Part A and built-app screenshots; VM artifacts can supply + persona, behavior, image/network, palette-live, and score evidence only when `run.json`, `score.json`, + `session_surface.final.json`, `network.ndjson`, and build SHA are present. Missing or mixed-SHA artifacts + must remain `partial` / `harness_contaminated`. ## Release (when RRI = 10/10 on a fresh .app build) Bump `.claude-plugin/plugin.json` → 1.0.4, tag `v1.0.4`, GitHub release + CHANGELOG. Then MAINTAIN: diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index 6a72c1ec..b2272156 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,7 +5,7 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-06-01T01:40:00+07:00 local Codex-DM built-app first-turn proof + AS OF: 2026-06-01T02:40:00+07:00 local Codex-DM built-app short playtest proof MAIN BASELINE: 6e03da4 (PRs #470, #471, #472, #473 merged; verified in /Users/lume/ClawDnD-val on 2026-06-01T01:40:00+07:00). Re-verify current `origin/main` before acting. @@ -25,18 +25,21 @@ personas failed around port/backend harness setup; behavioral/UI/palette/image evidence was not a valid five-persona release verdict. LAST BUILT-APP PLAY PROOF: - 2026-06-01T01:24:10+07:00 local `dist/WorldOS.app` proof with private BG art, Codex DM - provider, visible narration, enabled player actions, submitted `/move`, and - post-move DM response. Evidence lives under - /Volumes/LEXAR/Codex/worldos-built-app-proof/ - (`session-surface-racefix-after-dm-response-20260601T012410.json` and - `worldos-racefix-dm-response-dismissed-permission-20260601T012516.png`). + 2026-06-01T02:38:00+07:00 local `dist/WorldOS.app` short playtest on the + PR #475 Codex-DM path (`c3dfee6` app code): private BG art loaded, Codex + provider minted the live session, Arka seated, visible DM narration, five + enabled player actions, two `/move` submissions, two DM responses, and + `/session-surface` still actionable. Evidence: + /Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/ + (`02-first-turn-ready.png`, `04-after-move-1-dm-response.png`, + `06-after-move-2-dm-response.png`, `06-session-surface-after-move-2.json`). LAST VALID RELEASE GATE: none after the RRI contract hardening. A release verdict requires expected persona count, disk-backed palette/image/behavioral evidence, and built .app play. - NEXT ACTION: Run a short built-app gameplay playtest from the proven Codex-DM path, file/fix - the user-facing blockers it exposes, then run #466 for a trustworthy clean RRI - failure list/result on the 32GB support VM plus Mac-only built-app smoke. + NEXT ACTION: File/fix any user-facing blockers exposed by the short playtest, then run #466 + for a trustworthy clean RRI failure list/result: Mac/local or macOS CI supplies + built `.app` proof, while the 32GB support VM runs heavy backend/persona sweeps + after explicit VM preflight/auth setup. Keep sprint work UX-first (#467): first-turn playability, clickability/chrome, launcher clarity, live-response feel, and CRPG depth before more hardening/proxy/security work. DISCIPLINE: ≥2 clean reads before any fix (channel fabricates under host load); ONE heavy claude -p stream; @@ -176,7 +179,7 @@ verifier; can revert the goal to "fix" anytime. --- -## 9. CURRENT STATUS (2026-06-01T01:40:00+07:00 — first-turn proof achieved, NOT a release verdict) +## 9. CURRENT STATUS (2026-06-01T02:40:00+07:00 — short built-app playtest achieved, NOT a release verdict) - Repo truth stabilization merged in PR #465, UX-first doc sync merged in PR #468, and first-minute click/title chrome proof merged in PR #470. Local/Lexar/support-VM routing merged in PR #471. @@ -205,11 +208,21 @@ verifier; can revert the goal to "fix" anytime. macOS TCC attribution contamination: `responsible=dev.clawdnd.app`, but the actual accessor was `/usr/bin/find` launched by the test/diagnostic environment. Treat that screenshot prompt as harness contamination unless a clean run shows `WorldOSApp`/WebKit itself accessing a protected library path. -- The next evidence step is a short built-app gameplay playtest from this proven provider path, followed - by issue #466: a clean non-partial five-persona RRI from `6e03da4` or newer. Heavy backend/persona - sweeps belong on the owner-provided 32GB support VM (`support-vm-1`) once auth/config are intentionally - installed there; connection details are kept outside tracked docs. Mac-only built-app launch/play proof - stays on this Mac or macOS CI. +- The next gate evidence step is issue #466: a clean non-partial five-persona RRI from `6e03da4` or newer. + Heavy backend/persona sweeps belong on the owner-provided 32GB support VM (`support-vm-1`) once auth/config + are intentionally installed there; connection details are kept outside tracked docs. Mac-only built-app + launch/play proof stays on this Mac or macOS CI. +- Short built-app playtest proof now exists on the PR #475 Codex-DM path (`c3dfee6` app code) at + `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/`. The built app + minted a live Codex session on port 8766, showed private art, seated Arka as the active player, exposed + five enabled actions (`continue`, `say`, `do`, `check`, `save`), accepted two player `/move`s, produced + two DM responses, and left `/session-surface` with `can_act:true`. The final snapshot includes + `fist_checkpoint_exposed`, Nimra Ash-Tail, Sergeant Marrek Vale, and Trooper Elian Voss. This is + product progress and replaces "run a short built-app gameplay playtest" as the immediate objective. + It is still not RRI/release evidence because the five-persona gate has not run. +- Playtest finding filed as #476: the second Codex turn initially called `log_event` with `speaker: null`, causing a + validation error before the model retried without `speaker` and recovered. The player saw the final + narration, but this is a latency/noise risk to track if it repeats in #466. - Product direction is now UX-first (#467). Do not turn the next sprint into more gate hardening, proxy adapters, transport/security work, UGC/legal, or renderer branches unless #466 proves they block the player-facing session. The game must feel launchable, clickable, responsive, and deep before it needs more machinery. @@ -224,7 +237,9 @@ verifier; can revert the goal to "fix" anytime. Use the gate as evidence, not as the roadmap. The next sprint should optimize the felt session: -1. **Stretch first-turn proof into a short built-app playtest.** A fresh player can launch, +1. **Stretch first-turn proof into a short built-app playtest.** DONE diagnostically on PR #475 + (`c3dfee6` app code) in `codex-app-short-20260601T022114`; repeat after provider branch merge. + A fresh player can launch, choose/start/resume, reach the Table, submit multiple `/move`s, and see narration resolve without critical console/runtime errors. Evidence: built-app screenshots plus `/session-surface` and move/chat artifacts, not a proxy preview. diff --git a/qa/SCORECARD.md b/qa/SCORECARD.md index f4375293..bb01ab75 100644 --- a/qa/SCORECARD.md +++ b/qa/SCORECARD.md @@ -5,6 +5,15 @@ > `*` = RED-capped (gate failed → scores forced ≤ 2.5; not a real quality reading). > Scorer: claude `score.sh` unless noted `[oc]` (gpt-5.4, grades ~1.5 harsher). +## Built-app gameplay proof ledger + +> Diagnostic product evidence from the shipped Mac surface. These rows prove built-app behavior but do not +> replace the RRI release sweep below. + +| Run | Date | app/code SHA | Surface | Provider | Evidence | Result / notes | +|---|---|---|---|---|---|---| +| codex-app-short-20260601T022114 | 2026-06-01 | c3dfee6 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/` | **PASS diagnostic short playtest.** Private BG art visible; Arka seated; `/session-surface` live/actionable with five enabled actions; two `/move` submissions accepted; chat roles `dm, player, dm, player, dm`; final snapshot set `fist_checkpoint_exposed`. Not an RRI verdict. Finding filed as #476: one recovered `log_event(speaker:null)` validation error added latency/noise on move 2. | + | Run | Date | World | Persona | Model | Beats | Gate | Story | Mech | AngryDM | Change under test / notes | |---|---|---|---|---|---|---|---|---|---|---| | sprint-cs3 | 2026-05-27 | combat-sprint seed | angry-dm | sonnet | combat | **GREEN** | — | — | **4.2** | **Combat fidelity RESOLVED.** angry-dm 4.2 (was the 2.8–3.3 residual) — tool_fidelity 5, action_economy 5; the #159/#173/#180 surfacing+multiattack work paid off (DM now reads `_combat_numbers` + runs Multiattack). 4 defects all MED/LOW theater-of-mind edge cases (ranged-from-melee disadvantage, frightened-adjacency, unused Parry [wouldn't have flipped], dead Hold Person concentration) — the 4.2→4.5 stretch, tied to the no-positional-model limit. NOTE: scorer claude-p is slow → use timeout ≥1200 for combat-sprints (cs1/cs2 timed out mid-scoring). | diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index f9ac695f..c689b50d 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -50,6 +50,19 @@ def test_native_viewer_and_provider_forward_art_repo_env(self): self.assertIn('env["WORLDOS_ART_REPO_ROOT"] = preferences.artRepoPath', providers) self.assertIn('env["CLAWDND_ART_REPO_ROOT"] = preferences.artRepoPath', providers) + def test_built_app_playtest_can_keep_minted_backend_for_manual_gameplay(self): + harness = self.read("qa/ui_playtest_app.sh") + + self.assertIn("WOS_APP_KEEP_MINTED_BACKEND=1", harness) + self.assertIn("requires WOS_APP_PART=A", harness) + self.assertIn('KEEP_MINTED_BACKEND="${WOS_APP_KEEP_MINTED_BACKEND:-0}"', harness) + self.assertIn("keeping minted backend alive for gameplay proof", harness) + self.assertIn("kept_backend_alive", harness) + self.assertIn("PART_A_KEPT_BACKEND", harness) + self.assertIn("first_turn_ready", harness) + self.assertIn("waiting for first-turn readiness", harness) + self.assertIn('.actionModel.actor.name', harness) + def test_provider_viewer_stays_attached_during_native_restarts(self): root_view = self.read("macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift") app_process = self.read("macos/WorldOSApp/Sources/WorldOSApp/Services/AppProcessService.swift") diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index d454949d..af60c605 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -41,6 +41,11 @@ # WOS_APP_SKIP_BUILD=1 reuse an already-running .app (skip pkill/rebuild) — for fast inner loop. # WOS_APP_NO_GLOBAL_KILL=1 do not pkill other WorldOSApp processes (used for takeover smoke). # WOS_APP_PART=A|B|AB run only part A, only part B, or both (default AB). +# WOS_APP_KEEP_MINTED_BACKEND=1 +# part A only: leave the .app-minted provider backend alive so +# an operator can continue a short built-app gameplay playtest. +# Also waits for first-turn readiness: seated actor, enabled actions, +# and visible narration/chat, not merely can_act:true. # WORLDOS_DM_MODEL DM model (default sonnet). CLAWDND_PLAY_BUDGET caps each DM turn. # # Produces under qa/ui_playtest_runs//: @@ -58,6 +63,11 @@ PERSONA="${3:-newbie}" BEATS="${4:-6}" BUDGET="${5:-4.00}" PART="$(worldos_env APP_PART "${WOS_APP_PART:-AB}")" +KEEP_MINTED_BACKEND="${WOS_APP_KEEP_MINTED_BACKEND:-0}" +if [ "$KEEP_MINTED_BACKEND" = "1" ] && [ "$PART" != "A" ]; then + printf '[uipt-app] WOS_APP_KEEP_MINTED_BACKEND=1 requires WOS_APP_PART=A; refusing to mix kept native backend with part B.\n' >&2 + exit 2 +fi PW_DIR="$ROOT/qa/playwright" APP_BUNDLE="$ROOT/dist/WorldOS.app" @@ -118,7 +128,7 @@ dm_spend() { ############################################################################################### # PART A — NATIVE-TRANSITION GATE (re-verifies #356) ############################################################################################### -PART_A_RESULT="skipped"; PART_A_MINTED_PORT=""; PART_A_RUNDIR="" +PART_A_RESULT="skipped"; PART_A_MINTED_PORT=""; PART_A_RUNDIR=""; PART_A_KEPT_BACKEND="false"; PART_A_FIRST_TURN_READY="false" run_part_a() { log "=== PART A: native-transition gate (re-verifies #356) ===" local tlog="$NATIVE_DIR/transition.log"; : > "$tlog" @@ -302,20 +312,47 @@ PY local surf_final="{}" [ -n "$minted_port" ] && surf_final="$(curl -s "http://127.0.0.1:$minted_port/session-surface" 2>/dev/null | jq -c '{can_act,is_live_view,live,campaignId,enabledActions}' 2>/dev/null || echo '{}')" - # Tear down the minted session's BACKEND so its DM loop can't keep spending toward the .app's - # own $15 session budget (we only needed the cold-open to prove can_act:true). The .app process - # stays up; we kill the play.sh tree it shelled. We match BOTH the state-dir path AND the run-id - # POSITIONAL arg (`play[_party].sh `) — the supervisor + DM-loop bash procs - # carry the run id positionally, NOT the play-state path, so a path-only pkill leaves the - # viewer-respawning supervisor alive. We do NOT touch the launcher viewer. + if [ "$KEEP_MINTED_BACKEND" = "1" ] && [ -n "$minted_run" ] && [ -n "$minted_port" ]; then + a_log "[A] keep-alive proof: waiting for first-turn readiness (actor + enabled actions + narration)…" + local ready_surf actor enabled_count narration_count chat_lines + for _ in $(seq 1 80); do + ready_surf="$(curl -s --max-time 2 "http://127.0.0.1:$minted_port/session-surface" 2>/dev/null || echo '{}')" + actor="$(printf '%s' "$ready_surf" | jq -r '.actionModel.actor.name // ""' 2>/dev/null)" + enabled_count="$(printf '%s' "$ready_surf" | jq -r '(.enabledActions // []) | length' 2>/dev/null)" + narration_count="$(printf '%s' "$ready_surf" | jq -r '[.recentEvents[]? | select(.kind == "narration" or .kind == "dialogue")] | length' 2>/dev/null)" + chat_lines="$(wc -l < "$ROOT/play-state/$minted_run/chat.jsonl" 2>/dev/null | tr -d ' ')" + if [ -n "$actor" ] && [ "${enabled_count:-0}" -gt 0 ] && { [ "${narration_count:-0}" -gt 0 ] || [ "${chat_lines:-0}" -gt 0 ]; }; then + PART_A_FIRST_TURN_READY="true" + surf_final="$(printf '%s' "$ready_surf" | jq -c '{can_act,is_live_view,live,campaignId,enabledActions,actor:.actionModel.actor,recentEvents}' 2>/dev/null || printf '%s' "$ready_surf")" + a_log "[A] keep-alive proof ready: actor=$actor enabled=$enabled_count narration_events=${narration_count:-0} chat_lines=${chat_lines:-0}." + break + fi + sleep 3 + done + [ "$PART_A_FIRST_TURN_READY" = "true" ] || a_log "[A] keep-alive proof timed out before full first-turn readiness; continuing with native-transition result." + fi + + # Tear down the minted session's BACKEND by default so its DM loop can't keep spending toward + # the .app's own $15 session budget (we only needed the cold-open to prove can_act:true). + # For takeover gameplay proof, WOS_APP_KEEP_MINTED_BACKEND=1 intentionally leaves that exact + # app-minted backend alive so the operator can continue a short built-app playtest from the + # same session. The .app process stays up either way. When tearing down, we match BOTH the + # state-dir path AND the run-id POSITIONAL arg (`play[_party].sh `) — the + # supervisor + DM-loop bash procs carry the run id positionally, NOT the play-state path, so a + # path-only pkill leaves the viewer-respawning supervisor alive. We do NOT touch the launcher. if [ -n "$minted_run" ]; then - a_log "[A] tearing down minted backend (run=$minted_run) — cold-open was enough." - pkill -f "play-state/$minted_run/" 2>/dev/null || true - pkill -f "play_party.sh $WORLD $minted_run" 2>/dev/null || true - pkill -f "play.sh $WORLD $minted_run" 2>/dev/null || true - pkill -f "$WORLD $minted_run " 2>/dev/null || true - [ -n "$minted_port" ] && pkill -f "server.py .* $minted_port\$" 2>/dev/null || true - sleep 1 + if [ "$KEEP_MINTED_BACKEND" = "1" ]; then + PART_A_KEPT_BACKEND="true" + a_log "[A] WOS_APP_KEEP_MINTED_BACKEND=1 — keeping minted backend alive for gameplay proof (run=$minted_run port=${minted_port:-unknown})." + else + a_log "[A] tearing down minted backend (run=$minted_run) — cold-open was enough." + pkill -f "play-state/$minted_run/" 2>/dev/null || true + pkill -f "play_party.sh $WORLD $minted_run" 2>/dev/null || true + pkill -f "play.sh $WORLD $minted_run" 2>/dev/null || true + pkill -f "$WORLD $minted_run " 2>/dev/null || true + [ -n "$minted_port" ] && pkill -f "server.py .* $minted_port\$" 2>/dev/null || true + sleep 1 + fi fi if [ "$can_act" = "true" ] && [ -n "$minted_run" ]; then PART_A_RESULT="PASS" @@ -326,15 +363,18 @@ PY fi python3 - "$NATIVE_DIR/transition.json" "$PART_A_RESULT" "$BUILD_SHA" "$VERSION" \ - "${minted_run:-}" "${minted_port:-}" "$can_act" "$surf_final" <<'PY' + "${minted_run:-}" "${minted_port:-}" "$can_act" "$surf_final" \ + "$PART_A_KEPT_BACKEND" "$PART_A_FIRST_TURN_READY" <<'PY' import json, sys, datetime -out, result, sha, ver, run, port, can_act, surf = sys.argv[1:9] +out, result, sha, ver, run, port, can_act, surf, kept, first_turn_ready = sys.argv[1:11] try: surf_obj = json.loads(surf) except Exception: surf_obj = {"raw": surf} json.dump({ "gate": "native_transition_356", "result": result, "build_sha": sha, "version": ver, "minted_run_dir": run or None, "minted_port": int(port) if port else None, + "kept_backend_alive": kept == "true", + "first_turn_ready": first_turn_ready == "true", "can_act_after_click": can_act == "true", "session_surface_after": surf_obj, "before_png": "native/before.png", "after_png": "native/after.png", @@ -606,15 +646,18 @@ FINAL_DM_SPEND="$(dm_spend)" TOTAL_SPEND="$(awk -v a="${FINAL_DM_SPEND:-0}" -v b="${PART_B_PLAYER_COST:-0}" 'BEGIN{printf "%.4f", a+b}')" python3 - "$RUNDIR/run.json" "$RUN" "$WORLD" "$PERSONA" "$BEATS" "$BUDGET" "$BUILD_SHA" "$VERSION" \ "$PART" "$PART_A_RESULT" "${PART_A_RUNDIR:-}" "${PART_A_MINTED_PORT:-}" \ - "$PART_B_RESULT" "$PART_B_SCORE_PASS" "$FINAL_DM_SPEND" "$PART_B_PLAYER_COST" "$TOTAL_SPEND" <<'PY' + "$PART_A_KEPT_BACKEND" "$PART_A_FIRST_TURN_READY" "$PART_B_RESULT" "$PART_B_SCORE_PASS" \ + "$FINAL_DM_SPEND" "$PART_B_PLAYER_COST" "$TOTAL_SPEND" <<'PY' import json, sys, datetime (out, run, world, persona, beats, budget, sha, ver, part, a_res, a_run, a_port, - b_res, b_score_pass, dm_spend, player_cost, total) = sys.argv[1:18] + a_kept, a_first_turn_ready, b_res, b_score_pass, dm_spend, player_cost, total) = sys.argv[1:20] json.dump({ "run": run, "world": world, "persona": persona, "beats_cap": int(beats), "budget_usd": float(budget), "build_sha": sha, "version": ver, "part": part, "part_a": {"gate": "native_transition_356", "result": a_res, - "minted_run_dir": a_run or None, "minted_port": int(a_port) if a_port else None}, + "minted_run_dir": a_run or None, "minted_port": int(a_port) if a_port else None, + "kept_backend_alive": a_kept == "true", + "first_turn_ready": a_first_turn_ready == "true"}, "part_b": {"persona_loop": b_res, "score_pass": b_score_pass == "true"}, "spend_usd": {"dm_and_companions": round(float(dm_spend or 0), 4), "player_agent": round(float(player_cost or 0), 4), From cf88ccac82045c9594edc838e3acfbb39a5c4de0 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 02:57:08 +0700 Subject: [PATCH 05/10] Fix native playtest teardown matching --- qa/test_macos_app_static.py | 4 ++++ qa/ui_playtest_app.sh | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index c689b50d..39988262 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -62,6 +62,10 @@ def test_built_app_playtest_can_keep_minted_backend_for_manual_gameplay(self): self.assertIn("first_turn_ready", harness) self.assertIn("waiting for first-turn readiness", harness) self.assertIn('.actionModel.actor.name', harness) + self.assertIn('play_party.sh .* $minted_run', harness) + self.assertIn('play.sh .* $minted_run', harness) + self.assertNotIn('play_party.sh $WORLD $minted_run', harness) + self.assertNotIn('play.sh $WORLD $minted_run', harness) def test_provider_viewer_stays_attached_during_native_restarts(self): root_view = self.read("macos/WorldOSApp/Sources/WorldOSApp/Views/RootView.swift") diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index af60c605..97bfbf1c 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -347,9 +347,9 @@ PY else a_log "[A] tearing down minted backend (run=$minted_run) — cold-open was enough." pkill -f "play-state/$minted_run/" 2>/dev/null || true - pkill -f "play_party.sh $WORLD $minted_run" 2>/dev/null || true - pkill -f "play.sh $WORLD $minted_run" 2>/dev/null || true - pkill -f "$WORLD $minted_run " 2>/dev/null || true + pkill -f "play_party.sh .* $minted_run" 2>/dev/null || true + pkill -f "play.sh .* $minted_run" 2>/dev/null || true + pkill -f " $minted_run " 2>/dev/null || true [ -n "$minted_port" ] && pkill -f "server.py .* $minted_port\$" 2>/dev/null || true sleep 1 fi From e79144df743b47d35a6803def5849f6ac57e4166 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 03:18:52 +0700 Subject: [PATCH 06/10] Fix Codex DM null speaker logging --- scripts/play_codex_dm.sh | 8 ++++++++ servers/engine/server.py | 12 +++++++++--- servers/engine/tests/test_beat_roundtrip.py | 9 +++++++++ servers/engine/tests/test_codex_provider_wrapper.py | 9 +++++++++ servers/engine/tests/test_sessions.py | 6 ++++++ skills/dungeon-master/SKILL.md | 4 ++-- 6 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index 58ad4e1e..ed3a96d5 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -312,6 +312,8 @@ codex_dm_turn() { printf '%s' "$last" } +LOG_EVENT_TOOL_RULE="Tool argument rule: for log_event narration, omit the speaker argument entirely. For dialogue, pass a real non-empty character id or name. Never pass JSON null for speaker or any optional string field." + VPID_FILE="$RUN_DIR/.viewer.pid" viewer_supervisor() { while :; do @@ -345,6 +347,8 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend. +$LOG_EVENT_TOOL_RULE + Native-selected canon hero already seated: - campaign_id: "$HERO_CAMP" - player: "$HERO_PC_NAME" ($HERO_PC_RACE $HERO_PC_CLASS), id "$HERO_PC_ID" @@ -363,6 +367,8 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend. +$LOG_EVENT_TOOL_RULE + Start a live solo session: - call start_world("$CLAWDND_WORLD"); - call start_session for the campaign; @@ -392,6 +398,8 @@ while true; do chatlog player "$PMSG" if ! REPLY="$(codex_dm_turn "You are the Dungeon Master mid-session. Re-ground from the engine state first, then resolve this player move through the engine/rules tools and reply with 2nd-person player-facing narration. +$LOG_EVENT_TOOL_RULE + Player move: $PMSG")"; then fail "Codex DM move turn failed; see $STDERR_LOG" diff --git a/servers/engine/server.py b/servers/engine/server.py index ba901a63..e3bc1f77 100644 --- a/servers/engine/server.py +++ b/servers/engine/server.py @@ -137,7 +137,7 @@ def _log_session_entry( *, kind: str, text: str, - speaker: str = "", + speaker: Optional[str] = "", payload: Optional[dict] = None, ) -> SessionLogEntry: sid = _ensure_session(c) @@ -6670,7 +6670,13 @@ def list_slots(campaign_id: str) -> dict: @mcp.tool() -def log_event(campaign_id: str, kind: str, text: str, speaker: str = "", payload: Optional[dict] = None) -> dict: +def log_event( + campaign_id: str, + kind: str, + text: str, + speaker: Optional[str] = "", + payload: Optional[dict] = None, +) -> dict: """Record a story beat in the current session log (kind: narration | dialogue | roll | system | combat). Auto-starts a session if none is active. Powers recaps and post-compaction recovery.""" @@ -8460,7 +8466,7 @@ def persist_beat( c, kind=ev.get("kind", "narration"), text=ev.get("text", ""), - speaker=ev.get("speaker", ""), + speaker=ev.get("speaker") or "", payload=ev.get("payload"), ) logged.append(entry.model_dump()) diff --git a/servers/engine/tests/test_beat_roundtrip.py b/servers/engine/tests/test_beat_roundtrip.py index a7690f40..a805a820 100644 --- a/servers/engine/tests/test_beat_roundtrip.py +++ b/servers/engine/tests/test_beat_roundtrip.py @@ -104,6 +104,15 @@ def test_persist_beat_batches_logs_memories_decision_and_time(cid): assert (after.day, after.time_of_day) != (before.day, before.time_of_day) +def test_persist_beat_accepts_null_speaker(cid): + out = server.persist_beat( + cid, + events=[{"kind": "narration", "text": "The lantern gutters.", "speaker": None}], + ) + + assert out["logged"][0]["speaker"] is None + + def test_persist_beat_remember_dedupes_like_remember(cid): char = _a_char(cid) fact = "Vesper remembers the cold-eyed stranger." diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index 8b7e25f1..a96783c4 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -179,6 +179,15 @@ def test_codex_dm_wrapper_honors_native_selected_hero(): assert "Native-selected canon hero already seated" in source +def test_codex_dm_wrapper_forbids_null_speaker_arguments(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "LOG_EVENT_TOOL_RULE=" in source + assert "omit the speaker argument entirely" in source + assert "Never pass JSON null for speaker" in source + assert source.count("$LOG_EVENT_TOOL_RULE") >= 3 + + def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() diff --git a/servers/engine/tests/test_sessions.py b/servers/engine/tests/test_sessions.py index a02a9d7c..94fd9b90 100644 --- a/servers/engine/tests/test_sessions.py +++ b/servers/engine/tests/test_sessions.py @@ -39,6 +39,12 @@ def test_log_event_autostarts_and_tracks_session(cid): assert camp.session_ids == [camp.active_session_id] # tracked in history +def test_log_event_accepts_null_speaker_for_narration(cid): + out = server.log_event(cid, "narration", "The street goes still.", speaker=None) + + assert out["logged"]["speaker"] is None + + def test_plain_session_log_entry_parses_without_payload(): entry = SessionLogEntry.model_validate( {"kind": "combat", "text": "They felled the skeleton guardians."} diff --git a/skills/dungeon-master/SKILL.md b/skills/dungeon-master/SKILL.md index 03b0261f..bdd28c37 100644 --- a/skills/dungeon-master/SKILL.md +++ b/skills/dungeon-master/SKILL.md @@ -36,14 +36,14 @@ This is the heart of the experience. A "beat" is one exchange of the story. Run 1a. **The two-question per-beat GATE — run it the instant you've re-grounded, BEFORE you narrate or pick how to resolve.** These are the two craft misses the scorer flags most; both have full rules below, but the failure is never *knowing* them — it's not *checking* at the decision point. So make this a fixed checklist every single beat, right here at the top of the loop: - **(a) Did the player (or an NPC) address anyone present?** → that character **SPEAKS this beat** — a real, quoted, in-character line, never a summary ("Raphael considers…"), never silence. (Full rule: "NPCs SPEAK — and an ADDRESSED NPC speaks BACK," in Non-negotiables below.) - **(b) Is violence declared or imminent?** → **`start_combat` NOW**, not a `skill_check`, not prose. The trigger fires the instant the player declares OR *narrates* an attack/hostile spell on a foe ("I draw my blade and strike him", "Fire Bolt the enforcer"), OR hostiles confront the party with violence imminent (weapons drawn, ambush sprung, a foe out of words), OR the player escalates against a present hostile ("enough talk — take them"). Then `spawn_monster` any un-stat-blocked foe and run the engine loop (`attack`/`cast_spell`/`next_turn` → `end_combat`). **Narrative intent counts exactly as much as a structured `[attack]` — do NOT wait for a palette command, and do NOT settle a fight with a `skill_check` or "they go down in seconds."** (Full rule: "When combat is WARRANTED," in Non-negotiables below, and `reference/combat.md`.) This is NOT "every tense scene is a brawl" — a parley, a foe who hasn't attacked, a fight the player *chooses to avoid* stay social/skill beats; the gate fires only on declared/narrated/imminent-and-engaged violence. -2. **Narrate — and STREAM it: emit the opening/setup prose via `log_event(kind="narration", text=…)` as your first player-facing act, BEFORE you resolve any mechanics.** Describe the scene vividly and voice it; voice each NPC in their own `voice_id`. Logging the setup prose *now* (rather than holding it for the turn-end write) is what makes the scene appear on the player's dashboard within ~10–20s instead of after a 2-minute blank wait — a present NPC's line, the threat in the room, the choice taking shape all land *while* you go on to resolve the beat. (`log_event(kind="dialogue", text=…, speaker=…)` for a quoted line streams the same way.) **Honor `pacing_mode` from `get_state`:** `downtime` = let scenes breathe (social, shopping, recovery, character beats); `adventure` (default) = tension and momentum. On **first arrival at a location**, call `look_around` *before* narrating, and `get_scene` to pull any **authored beat** (read_aloud, dm_notes, check DCs) — run the author's intent in your own words: play the staged villain beat, the heartbreak line, the felt threat they wrote, rather than improvising past them. (When you're *generating* the world yourself, there's no authored scene — you are the author; see `reference/living-world.md`.) **Light up the visual layer in EVERY mode** (authored or generated): on first arrival at a location, `generate_image(kind="scene", scope=, prompt="")`; on a character's first on-screen appearance, `generate_image(kind="portrait", scope="portrait-"+, prompt="")`. The dashboard fetches `/image?scope=` (scene) and `/image?scope=portrait-` (faces). **Art is fire-and-forget — kick it off and move on; NEVER wait on it and NEVER block narration on the image.** `generate_image` returns immediately (it enqueues the work in the background, handing back a `status="pending"` handle); the picture lands in the dashboard a beat or two later and the panel shows a placeholder until it does. So fire it off and keep narrating — it's always safe and never on your turn's critical path (the default `null` provider is a no-op placeholder; a real provider generates off-turn). +2. **Narrate — and STREAM it: emit the opening/setup prose via `log_event(kind="narration", text=…)` as your first player-facing act, BEFORE you resolve any mechanics.** Describe the scene vividly and voice it; voice each NPC in their own `voice_id`. Logging the setup prose *now* (rather than holding it for the turn-end write) is what makes the scene appear on the player's dashboard within ~10–20s instead of after a 2-minute blank wait — a present NPC's line, the threat in the room, the choice taking shape all land *while* you go on to resolve the beat. (`log_event(kind="dialogue", text=…, speaker=…)` for a quoted line streams the same way.) **Tool argument rule:** narration has no speaker, so omit the `speaker` argument entirely for narration/system/combat rows unless a real non-empty character id/name exists; for dialogue, pass that real id/name. Never pass JSON `null` for `speaker` or any optional string field. **Honor `pacing_mode` from `get_state`:** `downtime` = let scenes breathe (social, shopping, recovery, character beats); `adventure` (default) = tension and momentum. On **first arrival at a location**, call `look_around` *before* narrating, and `get_scene` to pull any **authored beat** (read_aloud, dm_notes, check DCs) — run the author's intent in your own words: play the staged villain beat, the heartbreak line, the felt threat they wrote, rather than improvising past them. (When you're *generating* the world yourself, there's no authored scene — you are the author; see `reference/living-world.md`.) **Light up the visual layer in EVERY mode** (authored or generated): on first arrival at a location, `generate_image(kind="scene", scope=, prompt="")`; on a character's first on-screen appearance, `generate_image(kind="portrait", scope="portrait-"+, prompt="")`. The dashboard fetches `/image?scope=` (scene) and `/image?scope=portrait-` (faces). **Art is fire-and-forget — kick it off and move on; NEVER wait on it and NEVER block narration on the image.** `generate_image` returns immediately (it enqueues the work in the background, handing back a `status="pending"` handle); the picture lands in the dashboard a beat or two later and the panel shows a placeholder until it does. So fire it off and keep narrating — it's always safe and never on your turn's critical path (the default `null` provider is a no-op placeholder; a real provider generates off-turn). 3. **Companion reacts + advises — EVERY beat (the default, not a garnish).** Call `clawdnd-engine` `companion_advise(companion_id, situation=)`; it returns the companion's voice + personality + memory callbacks + a prompt. **Voice the companion's reaction and honest opinion** in their own voice — banter, worry, push-back, a plan. A companion that goes quiet is the #1 way this stops feeling like an adventure. They have goals and a past; let them show. 4. **Deliberate together** — when the party faces a real choice, let it be a *conversation*: the player weighs the companion's take, they may argue, then the player decides. Record the outcome with `record_decision(summary, options, chosen, rationale, actor_ids)` so it can be called back to later ("last time we trusted Grett…"). Big choices echo: schedule fallout with `add_consequence`. 5. **Player declares** their action (typed or spoken). **If they (or a companion) send a `clarify` question instead of an action** — "is the guard armed?", "how far is the door?", "do I know this sigil?" — just like a real table, **ANSWER it briefly first** (what their character could plausibly perceive or know) and do **NOT** roll, resolve, or advance the scene — a question is not a turn. **Then STOP and return the turn — do NOT narrate the PC acting on the answer.** Deliver only the information ("the lane to the booth is open; the enforcer's eyes are on his partner, not the room") and hand it back; never write "you're off the barstool and crossing the floor" — *deciding the PC's action for them on a clarify is the single most damaging agency violation, and it tanks scene-craft even when the prose is good.* The player saw the intel; let THEM choose to spring or hold. (The facade caps it at a few per turn, so it can't ping-pong.) 6. **Resolve via tools** — checks/attacks/rules through the engine. **For a skill check, call `skill_check(character_id, skill, dc)`** (or `social_check` when it targets an NPC's attitude) — they roll with the character's CORRECT modifier derived from the sheet. **Never hand-compute a bonus into a raw `roll()`** — that's the #1 mechanical error (a wrong ability mod, a missed proficiency). Use bare `roll()` only for dice that aren't a character's skill (a wandering-monster die, a random table). **If the move was an attack or a hostile spell on a foe — or hostiles are attacking — that is COMBAT: do NOT resolve it with a `skill_check` or narration. Call `start_combat` (+ `spawn_monster` for un-stat-blocked foes) FIRST**, then run the engine fight — see the "combat is WARRANTED" non-negotiable below and `reference/combat.md` (companion turns via `companion_suggest_action`, the action economy, the turn loop, damage/saves). Reach the companion only through its tool boundary; never silently skip its turn or fold its lines into your narration. **If a move arrives tagged `[set_seed_param] param=value`** (the player changed a World-Seed dial — tone / narration / GM strictness / chronicle voice / anachronism / chronicler's notes, or a gated rule like difficulty / permadeath / fate dice / item destruction — from the Seed screen), that is **DM-side configuration, not an in-scene action**: apply it with `set_seed_param(campaign_id, param, value)` (add `force=True` only if the player explicitly confirmed a retroactive mid-chronicle change — the tool returns `applied`/`warning`), then honor it going forward (it also surfaces on `get_state.seed_params`) and just briefly acknowledge it out-of-scene. Do **not** roll, advance the clock, or narrate the PC doing something for a seed-param move. 6a. **Stream the OUTCOME — emit the felt result via `log_event(kind="narration"/"dialogue", …)` as soon as the dice are in.** Now that the mechanics resolved, write the *felt* result of the roll/attack/spell (never the bare number — see "Dice live inside the tools") as another `log_event`, so the resolution streams onto the player's dashboard too, mid-turn — the scene built in step 2, and now the result lands while you finish bookkeeping. This is the second half of the streaming win: setup-prose first (step 2), outcome-prose here (6a), both live, *before* the turn ends. Between them, the player has watched the whole beat arrive instead of staring at a stalled counter. 7. **Persist STATE — LAST, off the critical path, in ONE call.** The player-facing prose is already on the dashboard — you streamed it via `log_event` in steps 2 + 6a (and you ALSO speak it as your reply text, step "Your turn's FINAL output", below). So `persist_beat` is pure STATE bookkeeping the player never waits on: save the whole beat's state in a **single** `persist_beat(campaign_id, …)` call (latency: N writes → 1 round-trip + 1 disk write) instead of separate `remember` / `record_decision` / `advance_time` hops. **Do NOT pass the player-facing narration you already streamed back through `persist_beat`'s `events=` — that re-logs the same prose to the session log a second time (a duplicate the dashboard would have to suppress, and a doubled line in the persisted record). The narration was authored once, live, via `log_event`; `persist_beat` carries state, not that prose.** - - **`events=[…]`** — leave EMPTY for the player-facing narration (you already streamed it via `log_event` in steps 2 + 6a). Use `events=` here only for a beat *record* row you did NOT stream live — e.g. a terse mechanical/`system` note for recall that was never player-facing. Each `{"kind":"narration|dialogue|…","text":…,"speaker"?:…}`. (Same as `log_event` — so a row you log live and a row you batch here are identical in the log; the rule is simply *log each player-facing paragraph exactly once*, and live is better. Loose prose only feeds recall if you log it — and steps 2 + 6a already did.) + - **`events=[…]`** — leave EMPTY for the player-facing narration (you already streamed it via `log_event` in steps 2 + 6a). Use `events=` here only for a beat *record* row you did NOT stream live — e.g. a terse mechanical/`system` note for recall that was never player-facing. Each `{"kind":"narration|dialogue|…","text":…,"speaker"?:…}`; omit `speaker` unless you have a real non-empty speaker, and never use `speaker:null`. (Same as `log_event` — so a row you log live and a row you batch here are identical in the log; the rule is simply *log each player-facing paragraph exactly once*, and live is better. Loose prose only feeds recall if you log it — and steps 2 + 6a already did.) - **`memories=[…]`** — significant NPC, companion, **and PC** moments, each `{"character_id":…,"fact":…}`. Target the *companion's* id after a real character beat (their pushback, a grief they voiced) so later `companion_advise` callbacks have material, **and the PC's own id for what the HERO learns or commits to** (the personal stake, a name recovered, a clue held) — PC and companion memory should be symmetric, not companion-only. (Same as `remember`; de-duped per character.) - **`decision={…}`** — when the beat had a real choice, `{"summary":…,"options":…,"chosen":…,"rationale":…,"actor_ids":[…],"sets_flag"?:…}`. (Same as `record_decision` — already lands in the recall index, as do `social_check` / `add_consequence`.) - **`advance={…}`** — when the fiction moved forward in time (an afternoon of legwork, a long conversation), `{"phases":N}` or `{"to":"evening"}`. A session whose clock never leaves *morning* is frozen — the QA gate fails it. (Same as `advance_time`, and a no-op during combat.) For a real **journey or a rest**, keep using `travel_to(..., advance_time=True)` / `long_rest` — those are their own beats, not a persist step (a `long_rest` now rolls to the next morning). From 803144549f2d4ddd385f135cb5c2b3adf1dd9461 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 03:31:27 +0700 Subject: [PATCH 07/10] Pass live campaign to Codex DM turns --- scripts/play_codex_dm.sh | 55 +++++++++++++++++++ .../tests/test_codex_provider_wrapper.py | 45 ++++++++++----- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index ed3a96d5..65ce6390 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -182,6 +182,51 @@ print(json.dumps({ PY } +discover_active_campaign_id() { + python3 - "$RUN_DIR" "${HERO_CAMP:-}" <<'PY' +import json +import sys +from pathlib import Path + +run_dir = Path(sys.argv[1]) +preferred = sys.argv[2].strip() +if preferred: + print(preferred) + raise SystemExit(0) + +candidates = [] +for snap in (run_dir / "campaigns").glob("*/snapshot.json"): + try: + data = json.loads(snap.read_text(encoding="utf-8")) + except Exception: + continue + cid = str(data.get("id") or snap.parent.name).strip() + if not cid: + continue + chars = data.get("characters") or {} + has_player = any(isinstance(ch, dict) and ch.get("kind") == "player" for ch in chars.values()) + active = bool(data.get("active_session_id")) + try: + mtime = snap.stat().st_mtime + except OSError: + mtime = 0 + candidates.append((active, has_player, mtime, cid)) + +if candidates: + candidates.sort() + print(candidates[-1][3]) +PY +} + +campaign_tool_hint() { + local cid="${1:-}" + if [ -n "${cid//[[:space:]]/}" ]; then + printf 'Live campaign_id: "%s". Call scene_context("%s") first; do not discover campaign state with shell commands, rg, find, or filesystem reads.\n' "$cid" "$cid" + else + printf 'If the live campaign_id is unknown, call list_campaigns once, then scene_context for the active campaign. Do not discover campaign state with shell commands, rg, find, or filesystem reads.\n' + fi +} + echo "[codex-dm-provider] run=$CLAWDND_RUN_ID world=$CLAWDND_WORLD port=$CLAWDND_PLAY_PORT mode=$MODE" echo "[codex-dm-provider] config=$CONFIG" echo "[codex-dm-provider] moves=$MOVES" @@ -313,6 +358,7 @@ codex_dm_turn() { } LOG_EVENT_TOOL_RULE="Tool argument rule: for log_event narration, omit the speaker argument entirely. For dialogue, pass a real non-empty character id or name. Never pass JSON null for speaker or any optional string field." +STATE_DISCOVERY_RULE="State discovery rule: after reading skills/dungeon-master/SKILL.md, use clawdnd-engine/clawdnd-rules MCP tools for live game state. Do not use shell commands, rg, find, or filesystem reads to discover campaign state." VPID_FILE="$RUN_DIR/.viewer.pid" viewer_supervisor() { @@ -348,6 +394,7 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend. $LOG_EVENT_TOOL_RULE +$STATE_DISCOVERY_RULE Native-selected canon hero already seated: - campaign_id: "$HERO_CAMP" @@ -368,6 +415,7 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend. $LOG_EVENT_TOOL_RULE +$STATE_DISCOVERY_RULE Start a live solo session: - call start_world("$CLAWDND_WORLD"); @@ -385,6 +433,9 @@ if ! OPENING="$(codex_dm_turn "$OPENING_PROMPT")"; then fi chatlog dm "$OPENING" +ACTIVE_CAMPAIGN_ID="$(discover_active_campaign_id)" +CAMPAIGN_TOOL_HINT="$(campaign_tool_hint "$ACTIVE_CAMPAIGN_ID")" + DM_TURNS=1 while true; do [ "$DM_TURNS" -ge "$CLAWDND_PLAY_MAX_TURNS" ] && break @@ -396,9 +447,13 @@ while true; do PMSG="$(printf '%s' "$new" | jq -rs 'map("[\(.kind)] \(.text // .name // "")") | join(" ")' 2>/dev/null)" [ -z "$PMSG" ] && continue chatlog player "$PMSG" + ACTIVE_CAMPAIGN_ID="$(discover_active_campaign_id)" + CAMPAIGN_TOOL_HINT="$(campaign_tool_hint "$ACTIVE_CAMPAIGN_ID")" if ! REPLY="$(codex_dm_turn "You are the Dungeon Master mid-session. Re-ground from the engine state first, then resolve this player move through the engine/rules tools and reply with 2nd-person player-facing narration. $LOG_EVENT_TOOL_RULE +$STATE_DISCOVERY_RULE +$CAMPAIGN_TOOL_HINT Player move: $PMSG")"; then diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index a96783c4..b933e6b2 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -188,6 +188,15 @@ def test_codex_dm_wrapper_forbids_null_speaker_arguments(): assert source.count("$LOG_EVENT_TOOL_RULE") >= 3 +def test_codex_dm_wrapper_prompts_use_engine_state_discovery(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "discover_active_campaign_id()" in source + assert "Live campaign_id:" in source + assert "Do not use shell commands, rg, find" in source + assert "CAMPAIGN_TOOL_HINT" in source + + def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -250,19 +259,29 @@ def test_codex_dm_wrapper_processes_moves_submitted_during_opening(tmp_path): *) shift ;; - esac -done -cat >/dev/null -marker="$CLAWDND_STATE_DIR/.fake-opening-seen" -if [ ! -f "$marker" ]; then - touch "$marker" - printf '{"role":"player","kind":"do","text":"queued during opening"}\\n' >> "$CLAWDND_STATE_DIR/player_moves.jsonl" - printf 'Opening narration from fake Codex.' > "$last" - printf '{"type":"result","result":"Opening narration from fake Codex."}\\n' -else - printf 'Second turn response from fake Codex.' > "$last" - printf '{"type":"result","result":"Second turn response from fake Codex."}\\n' -fi + esac + done + prompt="$(cat)" + marker="$CLAWDND_STATE_DIR/.fake-opening-seen" + if [ ! -f "$marker" ]; then + touch "$marker" + mkdir -p "$CLAWDND_STATE_DIR/campaigns/camp_fake" + printf '{"id":"camp_fake","active_session_id":"session_fake","characters":{"pc":{"kind":"player"}}}' > "$CLAWDND_STATE_DIR/campaigns/camp_fake/snapshot.json" + printf '{"role":"player","kind":"do","text":"queued during opening"}\\n' >> "$CLAWDND_STATE_DIR/player_moves.jsonl" + printf 'Opening narration from fake Codex.' > "$last" + printf '{"type":"result","result":"Opening narration from fake Codex."}\\n' + else + printf '%s' "$prompt" | grep -q 'Live campaign_id: "camp_fake"' || { + echo "missing live campaign hint" >&2 + exit 8 + } + printf '%s' "$prompt" | grep -q 'Do not use shell commands, rg, find' || { + echo "missing no-shell state discovery rule" >&2 + exit 9 + } + printf 'Second turn response from fake Codex.' > "$last" + printf '{"type":"result","result":"Second turn response from fake Codex."}\\n' + fi """, encoding="utf-8", ) From 500c379ad9b5e47aba213264af4032b371a30a63 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 03:37:00 +0700 Subject: [PATCH 08/10] Keep Codex DM solo openings companion-free --- scripts/play_codex_dm.sh | 8 ++++++++ servers/engine/tests/test_codex_provider_wrapper.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index 65ce6390..91c35a00 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -359,6 +359,11 @@ codex_dm_turn() { LOG_EVENT_TOOL_RULE="Tool argument rule: for log_event narration, omit the speaker argument entirely. For dialogue, pass a real non-empty character id or name. Never pass JSON null for speaker or any optional string field." STATE_DISCOVERY_RULE="State discovery rule: after reading skills/dungeon-master/SKILL.md, use clawdnd-engine/clawdnd-rules MCP tools for live game state. Do not use shell commands, rg, find, or filesystem reads to discover campaign state." +if [ -n "${CLAWDND_PLAY_COMPANIONS//[[:space:]]/}" ]; then + COMPANION_TOOL_RULE="Companion rule: only add companions named by CLAWDND_PLAY_COMPANIONS (${CLAWDND_PLAY_COMPANIONS}). Do not add any other companion to the party." +else + COMPANION_TOOL_RULE="Companion rule: this is a solo provider launch. Do not call load_canon_character with kind=\"companion\" or add any companion to the party; stage canon NPCs in narration only unless the player later recruits them." +fi VPID_FILE="$RUN_DIR/.viewer.pid" viewer_supervisor() { @@ -395,6 +400,7 @@ Before acting, read skills/dungeon-master/SKILL.md and follow its live-world con $LOG_EVENT_TOOL_RULE $STATE_DISCOVERY_RULE +$COMPANION_TOOL_RULE Native-selected canon hero already seated: - campaign_id: "$HERO_CAMP" @@ -416,6 +422,7 @@ Before acting, read skills/dungeon-master/SKILL.md and follow its live-world con $LOG_EVENT_TOOL_RULE $STATE_DISCOVERY_RULE +$COMPANION_TOOL_RULE Start a live solo session: - call start_world("$CLAWDND_WORLD"); @@ -454,6 +461,7 @@ while true; do $LOG_EVENT_TOOL_RULE $STATE_DISCOVERY_RULE $CAMPAIGN_TOOL_HINT +$COMPANION_TOOL_RULE Player move: $PMSG")"; then diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index b933e6b2..c4b89878 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -197,6 +197,15 @@ def test_codex_dm_wrapper_prompts_use_engine_state_discovery(): assert "CAMPAIGN_TOOL_HINT" in source +def test_codex_dm_wrapper_forbids_unconfigured_solo_companions(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "COMPANION_TOOL_RULE" in source + assert "this is a solo provider launch" in source + assert 'Do not call load_canon_character with kind=\\"companion\\"' in source + assert "only add companions named by CLAWDND_PLAY_COMPANIONS" in source + + def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() From a72df047469021d1cce3ed8a9ebc14ed2bf7fc21 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 03:47:44 +0700 Subject: [PATCH 09/10] Document final Codex DM app proof --- WorldOS-GUI-RUNBOOK.md | 11 +++++---- WorldOS-OPERATING-GOAL.md | 48 ++++++++++++++++++++------------------- qa/SCORECARD.md | 1 + 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index 5a7e7b6a..a3ee364a 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -32,11 +32,12 @@ not loaded app status yet, it omits `provider` and lets Swift's `selectedProviderRaw` setting decide. - The Codex path now has two wrappers: `scripts/play_codex_dm.sh` for the selected provider's DM loop, and `scripts/play_codex_actor.sh` for constrained player/companion actor work. Do not swap them. -- Do not treat the wrapper as release proof by itself. The 2026-06-01T02:38:00+07:00 local built-app playtest - (`/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/`) shows the Codex-DM - path can mint a live native session, load private BG art, seat Arka, show narration, expose five enabled - actions, accept two `/move`s, produce two DM responses, and leave `/session-surface` actionable. Release - still requires the full non-partial RRI gate. +- Do not treat the wrapper as release proof by itself. The 2026-06-01T03:40:26+07:00 local built-app proof + (`/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/`) shows the Codex-DM + path can mint a live native session, load private BG art, seat Alfira, show narration, expose five enabled + actions, accept and resolve a `/move`, and leave `/session-surface` actionable on exact PR #475 head + `500c379`. Release still requires the full non-partial RRI gate, and #479 remains the next provider-noise + fix before trusting RRI latency/playability evidence. ## Stand up the iteration surface (8799, playable, from canonical) ```bash diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index b2272156..37c71672 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,7 +5,7 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-06-01T02:40:00+07:00 local Codex-DM built-app short playtest proof + AS OF: 2026-06-01T03:41:00+07:00 local Codex-DM built-app exact-head play proof MAIN BASELINE: 6e03da4 (PRs #470, #471, #472, #473 merged; verified in /Users/lume/ClawDnD-val on 2026-06-01T01:40:00+07:00). Re-verify current `origin/main` before acting. @@ -25,21 +25,22 @@ personas failed around port/backend harness setup; behavioral/UI/palette/image evidence was not a valid five-persona release verdict. LAST BUILT-APP PLAY PROOF: - 2026-06-01T02:38:00+07:00 local `dist/WorldOS.app` short playtest on the - PR #475 Codex-DM path (`c3dfee6` app code): private BG art loaded, Codex - provider minted the live session, Arka seated, visible DM narration, five - enabled player actions, two `/move` submissions, two DM responses, and + 2026-06-01T03:40:26+07:00 local `dist/WorldOS.app` exact-head proof on the + PR #475 Codex-DM path (`500c379` app code): private BG art loaded, Codex + provider minted the live session, Alfira seated, visible DM narration, five + enabled player actions, one `/move` accepted and resolved, chat roles + `dm, player, dm`, no rejected `log_event(speaker:null)` calls, and `/session-surface` still actionable. Evidence: - /Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/ + /Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/ (`02-first-turn-ready.png`, `04-after-move-1-dm-response.png`, - `06-after-move-2-dm-response.png`, `06-session-surface-after-move-2.json`). + `04-session-surface-after-move-1.json`, `05-provider-trace-summary.json`). LAST VALID RELEASE GATE: none after the RRI contract hardening. A release verdict requires expected persona count, disk-backed palette/image/behavioral evidence, and built .app play. - NEXT ACTION: File/fix any user-facing blockers exposed by the short playtest, then run #466 - for a trustworthy clean RRI failure list/result: Mac/local or macOS CI supplies - built `.app` proof, while the 32GB support VM runs heavy backend/persona sweeps - after explicit VM preflight/auth setup. + NEXT ACTION: Fix #479 provider/tool-contract noise exposed by the final short playtest, + then run #466 for a trustworthy clean RRI failure list/result: Mac/local or + macOS CI supplies built `.app` proof, while the 32GB support VM runs heavy + backend/persona sweeps after explicit VM preflight/auth setup. Keep sprint work UX-first (#467): first-turn playability, clickability/chrome, launcher clarity, live-response feel, and CRPG depth before more hardening/proxy/security work. DISCIPLINE: ≥2 clean reads before any fix (channel fabricates under host load); ONE heavy claude -p stream; @@ -212,17 +213,18 @@ verifier; can revert the goal to "fix" anytime. Heavy backend/persona sweeps belong on the owner-provided 32GB support VM (`support-vm-1`) once auth/config are intentionally installed there; connection details are kept outside tracked docs. Mac-only built-app launch/play proof stays on this Mac or macOS CI. -- Short built-app playtest proof now exists on the PR #475 Codex-DM path (`c3dfee6` app code) at - `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/`. The built app - minted a live Codex session on port 8766, showed private art, seated Arka as the active player, exposed - five enabled actions (`continue`, `say`, `do`, `check`, `save`), accepted two player `/move`s, produced - two DM responses, and left `/session-surface` with `can_act:true`. The final snapshot includes - `fist_checkpoint_exposed`, Nimra Ash-Tail, Sergeant Marrek Vale, and Trooper Elian Voss. This is - product progress and replaces "run a short built-app gameplay playtest" as the immediate objective. - It is still not RRI/release evidence because the five-persona gate has not run. -- Playtest finding filed as #476: the second Codex turn initially called `log_event` with `speaker: null`, causing a - validation error before the model retried without `speaker` and recovered. The player saw the final - narration, but this is a latency/noise risk to track if it repeats in #466. +- Exact-head built-app proof now exists on the PR #475 Codex-DM path (`500c379` app code) at + `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/`. The built app + minted a live Codex session on port 8766, showed private art, seated Alfira as the active player, exposed + five enabled actions (`continue`, `say`, `do`, `check`, `save`), accepted and resolved one player `/move`, + and left `/session-surface` with `can_act:true`. The provider trace showed no rejected + `log_event(speaker:null)` calls (#476 fixed on PR #475) and no broad `rg`/`find` campaign-state scans + after the player move (#477 fixed on PR #475). Unconfigured companion loading from solo openings is also + fixed (#478). This is product progress, but it is still not RRI/release evidence because the five-persona + gate has not run. +- Remaining playtest finding filed as #479: setup/social tool-contract safety cancellations still add + latency/noise even though the player-facing session recovers. Fix this before trusting the full #466 RRI + latency/playability read. - Product direction is now UX-first (#467). Do not turn the next sprint into more gate hardening, proxy adapters, transport/security work, UGC/legal, or renderer branches unless #466 proves they block the player-facing session. The game must feel launchable, clickable, responsive, and deep before it needs more machinery. @@ -238,7 +240,7 @@ verifier; can revert the goal to "fix" anytime. Use the gate as evidence, not as the roadmap. The next sprint should optimize the felt session: 1. **Stretch first-turn proof into a short built-app playtest.** DONE diagnostically on PR #475 - (`c3dfee6` app code) in `codex-app-short-20260601T022114`; repeat after provider branch merge. + (`500c379` app code) in `codex-app-final-20260601T033714`; repeat after provider branch merge. A fresh player can launch, choose/start/resume, reach the Table, submit multiple `/move`s, and see narration resolve without critical console/runtime errors. Evidence: built-app screenshots plus `/session-surface` and move/chat diff --git a/qa/SCORECARD.md b/qa/SCORECARD.md index bb01ab75..f86bc5ed 100644 --- a/qa/SCORECARD.md +++ b/qa/SCORECARD.md @@ -12,6 +12,7 @@ | Run | Date | app/code SHA | Surface | Provider | Evidence | Result / notes | |---|---|---|---|---|---|---| +| codex-app-final-20260601T033714 | 2026-06-01 | 500c379 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/` | **PASS diagnostic exact-head first-turn + one-move play proof.** Private BG art visible; Alfira seated; `/session-surface` live/actionable with five enabled actions; one `/move` accepted and resolved; chat roles `dm, player, dm`; no rejected `log_event(speaker:null)` calls; mid-session state discovery used `scene_context(campaign_id=...)` with no broad `rg`/`find` state scans. Not an RRI verdict. Remaining provider/tool-contract noise tracked as #479: safety-cancelled setup/social calls still add latency. | | codex-app-short-20260601T022114 | 2026-06-01 | c3dfee6 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/` | **PASS diagnostic short playtest.** Private BG art visible; Arka seated; `/session-surface` live/actionable with five enabled actions; two `/move` submissions accepted; chat roles `dm, player, dm, player, dm`; final snapshot set `fist_checkpoint_exposed`. Not an RRI verdict. Finding filed as #476: one recovered `log_event(speaker:null)` validation error added latency/noise on move 2. | | Run | Date | World | Persona | Model | Beats | Gate | Story | Mech | AngryDM | Change under test / notes | From d292defeca2557cb576f2ab65e3e62cdaf837541 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 1 Jun 2026 04:33:50 +0700 Subject: [PATCH 10/10] Stabilize Codex DM built-app proof --- WorldOS-GUI-RUNBOOK.md | 22 +- WorldOS-OPERATING-GOAL.md | 55 +-- WorldOS-RUNBOOK.md | 6 +- docs/AGENT_GRADE_APP_TESTABILITY.md | 304 ++++++++++++++ qa/SCORECARD.md | 3 + qa/export_app_evidence.py | 376 ++++++++++++++++++ qa/test_export_app_evidence.py | 131 ++++++ qa/test_macos_app_static.py | 5 +- qa/ui_playtest_app.sh | 25 +- scripts/play_codex_dm.sh | 108 +++-- .../tests/test_codex_provider_wrapper.py | 45 ++- viewer/server.py | 138 +++++++ viewer/tests/test_openworlds_static.py | 66 +++ 13 files changed, 1219 insertions(+), 65 deletions(-) create mode 100644 docs/AGENT_GRADE_APP_TESTABILITY.md create mode 100644 qa/export_app_evidence.py create mode 100644 qa/test_export_app_evidence.py diff --git a/WorldOS-GUI-RUNBOOK.md b/WorldOS-GUI-RUNBOOK.md index a3ee364a..02c43c76 100644 --- a/WorldOS-GUI-RUNBOOK.md +++ b/WorldOS-GUI-RUNBOOK.md @@ -32,12 +32,24 @@ not loaded app status yet, it omits `provider` and lets Swift's `selectedProviderRaw` setting decide. - The Codex path now has two wrappers: `scripts/play_codex_dm.sh` for the selected provider's DM loop, and `scripts/play_codex_actor.sh` for constrained player/companion actor work. Do not swap them. -- Do not treat the wrapper as release proof by itself. The 2026-06-01T03:40:26+07:00 local built-app proof - (`/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/`) shows the Codex-DM +- Do not treat the wrapper as release proof by itself. The 2026-06-01T04:39:09+07:00 local built-app proof + (`/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-headproof-20260601T043909/`) shows the Codex-DM path can mint a live native session, load private BG art, seat Alfira, show narration, expose five enabled - actions, accept and resolve a `/move`, and leave `/session-surface` actionable on exact PR #475 head - `500c379`. Release still requires the full non-partial RRI gate, and #479 remains the next provider-noise - fix before trusting RRI latency/playability evidence. + actions, accept and resolve a `/move`, leave `/session-surface` actionable, and produce a provider trace + with zero errors/failed tool calls on PR #475 app-code commit `8bd833f`. Release still requires the full + non-partial RRI gate. + +## Agent-facing app contract + +- `GET /app-status` and `GET /__worldos/app-status.json` are read-only probes for agents and harnesses. + They report build/version, viewer port, state root, provider, private-art root presence, live campaign/run, + move sink, active actor, enabled actions, and canonical endpoints. They must not mutate campaign state. +- Use `/app-status` before screenshots when diagnosing the built app. It answers: "am I on the real live + campaign, can the player act, where is the move sink, and is private art configured?" +- `qa/ui_playtest_app.sh` captures launcher and minted-provider `app-status` JSON into the native evidence + folder. A built-app proof that cannot produce this status object is a harness/product observability failure. +- Longer-term agent-grade testing lives under #480-#486: deterministic smoke provider, stable accessibility + and DOM hooks, crisp failure buckets, and one exported evidence bundle per app playtest. ## Stand up the iteration surface (8799, playable, from canonical) ```bash diff --git a/WorldOS-OPERATING-GOAL.md b/WorldOS-OPERATING-GOAL.md index 37c71672..9acce2a5 100644 --- a/WorldOS-OPERATING-GOAL.md +++ b/WorldOS-OPERATING-GOAL.md @@ -5,7 +5,7 @@ Post-compaction agents: this 6-line block is ground truth. Do NOT reconstruct state from scattered docs or old plans; trust this, verify the sha, then act. ────────────────────────────────────────────────────────────────────────── - AS OF: 2026-06-01T03:41:00+07:00 local Codex-DM built-app exact-head play proof + AS OF: 2026-06-01T04:39:09+07:00 local Codex-DM built-app trace-clean play proof MAIN BASELINE: 6e03da4 (PRs #470, #471, #472, #473 merged; verified in /Users/lume/ClawDnD-val on 2026-06-01T01:40:00+07:00). Re-verify current `origin/main` before acting. @@ -25,22 +25,26 @@ personas failed around port/backend harness setup; behavioral/UI/palette/image evidence was not a valid five-persona release verdict. LAST BUILT-APP PLAY PROOF: - 2026-06-01T03:40:26+07:00 local `dist/WorldOS.app` exact-head proof on the - PR #475 Codex-DM path (`500c379` app code): private BG art loaded, Codex - provider minted the live session, Alfira seated, visible DM narration, five - enabled player actions, one `/move` accepted and resolved, chat roles - `dm, player, dm`, no rejected `log_event(speaker:null)` calls, and - `/session-surface` still actionable. Evidence: - /Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/ - (`02-first-turn-ready.png`, `04-after-move-1-dm-response.png`, - `04-session-surface-after-move-1.json`, `05-provider-trace-summary.json`). + 2026-06-01T04:39:09+07:00 local `dist/WorldOS.app` proof on PR #475 + Codex-DM app code `8bd833f` (`codex-app-headproof-20260601T043909`): + private BG art loaded, Codex provider minted the live session, Alfira + seated, visible DM narration, five enabled player actions, one `/move` + accepted/resolved, chat roles `dm, player, dm`, `/session-surface` still + actionable, and provider trace had zero errors/failed tool calls. Evidence: + /Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-headproof-20260601T043909/ + (`native/app-status.after-move.json`, + `native/session-surface.after-move.json`, `native/provider-errors.json`, + `play-state/chat.jsonl`, `play-state/player_moves.jsonl`, + `app-evidence/manifest.json`). LAST VALID RELEASE GATE: none after the RRI contract hardening. A release verdict requires expected persona count, disk-backed palette/image/behavioral evidence, and built .app play. - NEXT ACTION: Fix #479 provider/tool-contract noise exposed by the final short playtest, - then run #466 for a trustworthy clean RRI failure list/result: Mac/local or - macOS CI supplies built `.app` proof, while the 32GB support VM runs heavy - backend/persona sweeps after explicit VM preflight/auth setup. + NEXT ACTION: Treat #479 as fixed on PR #475 after merge, then run #466 for a trustworthy + clean RRI failure list/result: Mac/local or macOS CI supplies built `.app` + proof, while the 32GB support VM runs heavy backend/persona sweeps after + explicit VM preflight/auth setup. In parallel, continue the agent-grade + app testability lane (#480-#486): `/app-status`, deterministic smoke + provider, stable a11y/DOM hooks, and one-bundle evidence export. Keep sprint work UX-first (#467): first-turn playability, clickability/chrome, launcher clarity, live-response feel, and CRPG depth before more hardening/proxy/security work. DISCIPLINE: ≥2 clean reads before any fix (channel fabricates under host load); ONE heavy claude -p stream; @@ -180,7 +184,7 @@ verifier; can revert the goal to "fix" anytime. --- -## 9. CURRENT STATUS (2026-06-01T02:40:00+07:00 — short built-app playtest achieved, NOT a release verdict) +## 9. CURRENT STATUS (2026-06-01T04:39:09+07:00 — trace-clean built-app play proof achieved, NOT a release verdict) - Repo truth stabilization merged in PR #465, UX-first doc sync merged in PR #468, and first-minute click/title chrome proof merged in PR #470. Local/Lexar/support-VM routing merged in PR #471. @@ -213,18 +217,19 @@ verifier; can revert the goal to "fix" anytime. Heavy backend/persona sweeps belong on the owner-provided 32GB support VM (`support-vm-1`) once auth/config are intentionally installed there; connection details are kept outside tracked docs. Mac-only built-app launch/play proof stays on this Mac or macOS CI. -- Exact-head built-app proof now exists on the PR #475 Codex-DM path (`500c379` app code) at - `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/`. The built app +- Trace-clean built-app proof now exists on the PR #475 Codex-DM app-code commit `8bd833f` at + `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-headproof-20260601T043909/`. The built app minted a live Codex session on port 8766, showed private art, seated Alfira as the active player, exposed five enabled actions (`continue`, `say`, `do`, `check`, `save`), accepted and resolved one player `/move`, - and left `/session-surface` with `can_act:true`. The provider trace showed no rejected - `log_event(speaker:null)` calls (#476 fixed on PR #475) and no broad `rg`/`find` campaign-state scans - after the player move (#477 fixed on PR #475). Unconfigured companion loading from solo openings is also - fixed (#478). This is product progress, but it is still not RRI/release evidence because the five-persona - gate has not run. -- Remaining playtest finding filed as #479: setup/social tool-contract safety cancellations still add - latency/noise even though the player-facing session recovers. Fix this before trusting the full #466 RRI - latency/playability read. + wrote chat roles `dm, player, dm`, and left `/session-surface` with `can_act:true`. The provider trace + summary for this run reports zero errors and zero failed tool calls. Earlier PR #475 playtests fixed the + `log_event(speaker:null)` issue (#476), broad `rg`/`find` state discovery (#477), unconfigured solo + companion loading (#478), and the setup/social tool-contract cancellations (#479). This is product progress, + but it is still not RRI/release evidence because the five-persona gate has not run. +- The first app-native testability layer now exists: `GET /app-status` exposes the live run, campaign, + provider, private-art presence, move sink, actor, enabled actions, and evidence paths without mutating + state. This is not a substitute for visual/app testing, but it gives agents a deterministic product + contract so they stop guessing from screenshots, ports, stale windows, and private-art path archaeology. - Product direction is now UX-first (#467). Do not turn the next sprint into more gate hardening, proxy adapters, transport/security work, UGC/legal, or renderer branches unless #466 proves they block the player-facing session. The game must feel launchable, clickable, responsive, and deep before it needs more machinery. diff --git a/WorldOS-RUNBOOK.md b/WorldOS-RUNBOOK.md index 47d0ba10..b2cf6ba5 100644 --- a/WorldOS-RUNBOOK.md +++ b/WorldOS-RUNBOOK.md @@ -25,7 +25,7 @@ > If an operator hands you local session notes or decision records, treat them as > private working artifacts unless they are intentionally promoted into tracked docs. > -> Last updated: 2026-06-01T01:40:00+07:00 (Codex-DM built-app first-turn proof + TCC test-contamination note; release notes below are historical context). +> Last updated: 2026-06-01T04:39:09+07:00 (trace-clean Codex-DM built-app play proof + `/app-status` agent observability; release notes below are historical context). > > **Graphics & game-types roadmap (canonical):** the long-term plan for the kinds of games > WorldOS can produce (GT0 narrative dashboard → GT1 SNES pixel → GT2 Pillars/BG isometric) @@ -394,6 +394,10 @@ lands on app relaunch with NO Swift rebuild** (the swift build is a ~0.1s no-op) Keep `scripts/play_codex_actor.sh` as the constrained player/companion actor helper through `player_server.py`; it is not the native provider's DM loop. OpenClaw still requires an explicit configured command before it can be treated as a startable provider. +- Agents and app harnesses should read `GET /app-status` before trying to infer state from pixels or + process lists. It is a read-only contract for the live OpenWorlds surface: provider, run/state roots, + private-art presence, active campaign/session, move sink, actor, enabled actions, and canonical endpoints. + The built-app harness captures launcher and minted-provider app-status JSON as release evidence. - **`can_act = _live_play() AND is_live_view`**, and `is_live_view` requires `cid == self.campaign_id`. The viewer launches with an EMPTY campaign id; `_resolve_campaign` lazily sets `self.campaign_id` to the **current** campaign (`_pick_campaign`). So the diff --git a/docs/AGENT_GRADE_APP_TESTABILITY.md b/docs/AGENT_GRADE_APP_TESTABILITY.md new file mode 100644 index 00000000..22d99660 --- /dev/null +++ b/docs/AGENT_GRADE_APP_TESTABILITY.md @@ -0,0 +1,304 @@ +# Agent-Grade App Testability Contract + +This document defines the architecture contract for milestone +[Agent-Grade App Testability - Built-App Harness & Evidence](https://github.com/electricsheephq/WorldOS/milestone/19), +parent issue [#480](https://github.com/electricsheephq/WorldOS/issues/480). + +The goal is narrow: make the shipped `dist/WorldOS.app` testable by an agent with +deterministic status, stable driving hooks, and disk-backed evidence. This does +not change the game architecture: the engine remains the sole writer, the native +app/OpenWorlds viewer remains a thin reader plus `/move` intent submitter, and +the built app remains release truth. + +## Related Work + +- [#324](https://github.com/electricsheephq/WorldOS/issues/324): AI playtester harness with five personas. +- [#466](https://github.com/electricsheephq/WorldOS/issues/466): clean non-partial five-persona RRI. +- [#467](https://github.com/electricsheephq/WorldOS/issues/467): UX-first release readiness. +- [#479](https://github.com/electricsheephq/WorldOS/issues/479): real-provider tool-contract noise. +- [#481](https://github.com/electricsheephq/WorldOS/issues/481): expose `app-status` v1. +- [#482](https://github.com/electricsheephq/WorldOS/issues/482): deterministic scripted DM provider. +- [#483](https://github.com/electricsheephq/WorldOS/issues/483): crisp built-app harness failure buckets. +- [#484](https://github.com/electricsheephq/WorldOS/issues/484): stable accessibility and agent-driving hooks. +- [#485](https://github.com/electricsheephq/WorldOS/issues/485): one evidence bundle per app playtest. +- [#486](https://github.com/electricsheephq/WorldOS/issues/486): split smoke, provider playtest, and RRI gates. + +## App-Status v1 + +`app-status` v1 is a read-only behavior contract for the live OpenWorlds +surface. The built-app harness must be able to fetch it without causing any +campaign mutation. Missing or malformed status is a harness failure, not an +implicit pass. + +Preferred surface: `GET /app-status` from the viewer process serving the built +app. Native bridge implementations may also mirror the same object, but the JSON +shape and semantics must stay identical. + +Current v1 minimum, implemented first so agents can stop guessing: + +```json +{ + "schema": "worldos.app-status.v1", + "surface": "openworlds", + "state_authority": "engine", + "write_lane": "/move", + "build": { + "sha": "string", + "version": "string" + }, + "viewer": { + "port": 8766, + "repo_root": "string", + "state_root": "string", + "provider": "codex|claude|...", + "chat_path": "string", + "chat_lines": 0 + }, + "art": { + "repo_root": "string", + "private_root": "string", + "private_root_present": true + }, + "live": { + "attached_campaign_id": "string", + "campaign_id": "string", + "active_session_id": "string", + "run_id": "string", + "moves_path": "string", + "moves_writable": true, + "is_live_view": true, + "can_act": false, + "actor": {"id": "string", "name": "string", "kind": "player"}, + "enabled_action_ids": ["continue", "say", "do", "check", "save"], + "enabled_action_count": 5 + }, + "endpoints": { + "app_status": "/app-status", + "session_surface": "/session-surface", + "move": "/move" + } +} +``` + +Target expansion for #481/#483: + +```json +{ + "status": "booting|ready|degraded|blocked", + "ready_for_smoke": false, + "ready_for_play": false, + "build": {"app_sha": "string", "bundle_id": "dev.clawdnd.app"}, + "surface_detail": { + "kind": "built_app|dev_viewer", + "route": "/openworlds/", + "viewer_url": "http://127.0.0.1:/openworlds/", + "native_window_ready": false + }, + "provider_detail": { + "id": "deterministic-smoke|codex|claude|...", + "mode": "deterministic|real", + "ready": false, + "dev_test_provider_enabled": false + }, + "health": { + "console_errors": 0, + "network_failures": 0, + "last_error": "string|null", + "failure_bucket": "none|app_not_running|viewer_unreachable|openworlds_not_loaded|provider_unavailable|test_provider_disabled|session_missing|player_not_seated|palette_disabled|move_sink_missing|private_art_missing|image_probe_failed|console_error|network_error|timeout|unknown" + } +} +``` + +Behavioral rules: + +- `ready_for_smoke` requires built app launch, OpenWorlds route loaded, status + fetchable, provider ready, private-art image probe success, a seated living + player, visible narration, at least one enabled player action, and `/move` + sink readiness. +- `ready_for_play` is stronger: it also requires the selected provider to be a + real provider or an explicitly enabled deterministic test provider, and it must + report no blocking console/network failures. +- `degraded` means the app is observable but not fully playable; include a + failure bucket. `blocked` means the harness cannot continue safely. +- Status must never expose private art file contents, secrets, model keys, or + operator-only VM details. Paths may be omitted or redacted when not needed for + diagnosis. + +## Deterministic Smoke Provider + +The deterministic smoke provider is a dev/test-only DM provider for built-app +smoke. It exists to prove app wiring deterministically before spending budget on +real providers. + +Contract: + +- Provider id: `deterministic-smoke`. +- No network calls, model calls, randomness without a recorded seed, or external + auth. +- Enabled only when an explicit dev/test gate is set, for example + `WORLDOS_ENABLE_TEST_PROVIDERS=1`. If requested without the gate, app-status + reports `failure_bucket: "test_provider_disabled"` and the app refuses to run + it. +- Uses the normal engine/player architecture. It may script the DM response, but + campaign state is still written only by the engine and player input still + enters as `/move`. +- Seats a living canon player, emits visible DM narration, exposes enabled + actions, accepts one representative `/move`, resolves a deterministic follow-up + turn, and leaves `/session-surface` actionable. +- Is never release proof by itself. It is a wiring smoke for #482/#483/#486. + +## Accessibility and Driving Hooks + +Agents should prefer semantic accessibility over implementation-specific DOM +shape. `data-testid` is allowed when role/name is ambiguous or when copy changes +would make tests brittle. + +Policy: + +- Every agent-driven control has a stable accessible role, name, disabled state, + and focus behavior. +- Important regions expose stable landmarks or labels: launcher, campaign shelf, + OpenWorlds root, narration log, active-player panel, action palette, move + composer, provider/status banner, modal/dialog layer. +- `data-testid` values are stable public test hooks, not CSS hooks. Do not rename + or remove one without updating the harness in the same change. +- Prefer generic test ids plus state attributes for repeated controls, for + example `data-testid="action-button"` with `data-action-id="say"` rather than + embedding volatile label text in the test id. +- Narration/progress surfaces use `aria-live` or an equivalent observable update + marker so an agent can tell whether a turn advanced. +- Hooks must not reveal private art paths, secrets, or internal provider prompts. + +Minimum hook set for #484: + +- `worldos-launcher` +- `chronicle-start` +- `chronicle-resume` +- `openworlds-root` +- `app-status-banner` +- `narration-log` +- `active-player` +- `action-palette` +- `action-button` plus `data-action-id` +- `move-composer` +- `turn-progress` + +## Evidence Bundle + +Every built-app smoke or playtest writes one bundle under: + +`/Volumes/LEXAR/Codex/worldos-agent-grade-app-testability//` + +The run id should include date/time, short SHA, provider, and gate kind. The +harness should write to a temporary directory first and atomically promote the +completed bundle so partial runs are recognizable. + +Required contents for #485: + +- `manifest.json`: schema version, run id, gate kind, repo, branch, commit SHA, + dirty-state flag, app SHA/version, provider id, world/persona, command, + start/end timestamps, artifact root, and final verdict. +- `app-status.initial.json` and `app-status.final.json`. +- `session-surface.initial.json` and `session-surface.final.json` when a session + was reached. +- `screenshots/`: launch, first playable surface, after first move, and final + state at minimum. +- `a11y/`: matching accessibility snapshots for the same moments. +- `console.ndjson` and `network.ndjson`: passive browser/app failures. +- `actions.ndjson`: agent or harness actions with result, timestamp, and target. +- `moves.ndjson`: submitted `/move` intents and accept/reject result. +- `provider_trace.ndjson`: provider-level steps, redacted for secrets. +- `run.log`: harness stdout/stderr. +- `summary.md`: human-readable verdict with failure bucket and next action. +- Gate-specific score output: `smoke.json`, `provider_playtest.json`, or + `RRI.json`. + +Bundles are evidence, not source. Do not commit them. Screenshots may contain +private art and must remain in `/Volumes/LEXAR/Codex` unless the owner explicitly +chooses to publish a redacted excerpt. + +## Gate Split + +Issue #486 defines three distinct gates. They must not be collapsed into one +score. + +### 1. Deterministic Built-App Smoke + +Purpose: fast, repeatable app wiring proof. + +Surface: rebuilt `dist/WorldOS.app`, deterministic smoke provider, app-status +v1, stable hooks, one `/move`. + +Pass requires: + +- Native app launches and serves `/openworlds/`. +- Current v1: `app-status.live.can_act` is true, `moves_writable` is true, an + actor is seated, enabled actions are non-empty, and private art is present. + Future expansion: `app-status` reaches `ready_for_smoke: true`. +- Private art probe succeeds without committing art. +- A living player is seated, narration is visible, actions are enabled, and one + `/move` is accepted and resolved. +- Evidence bundle exists with no missing required files. + +This gate catches wiring failures early. It does not prove provider quality, +latency, story quality, or release readiness. + +### 2. Short Real-Provider Playtest + +Purpose: prove the real selected provider can drive the built app without +contract noise that breaks the player-facing loop. + +Surface: rebuilt `dist/WorldOS.app`, real provider such as Codex/Claude, short +budget, one to two player moves, evidence bundle. + +Pass requires deterministic smoke already passing, plus: + +- Provider starts without auth/setup cancellation loops. +- First playable turn appears on the built app. +- At least one real `/move` is accepted and resolved. +- Provider trace has no blocking validation/safety cancellations. + +This is where #479 belongs. It is diagnostic release evidence, not a replacement +for the five-persona RRI. + +### 3. Full Non-Partial Five-Persona RRI + +Purpose: release-grade verdict. + +Surface: one rebuilt app SHA, the #324 persona set (`newbie`, `veteran`, +`adversarial`, `narrative`, `optimizer`), disk-backed evidence, and +`qa/release_readiness.py`. + +Pass requires #466 conditions: + +- All expected personas complete or are explicitly reported missing. +- No partial or harness-contaminated verdict. +- Same build SHA across evidence. +- Disk-backed behavior, UI, image, palette, console/network, score, and + session-surface evidence. +- RRI returns non-partial release-ready status. + +This is the only release gate. The UX-first roadmap in #467 should use its +failures as product evidence, not as a reason to build more proxy machinery. + +## Non-Goals + +- No engine write-path changes. +- No new gameplay mechanics, content, renderer branch, or UI redesign. +- No committed private art or generated evidence bundles. +- No replacement for #324 or #466. +- No treating dev viewer, proxy port, deterministic provider, or one-turn + provider proof as release truth. +- No broad local persona sweeps on the 16 GB Mac when CI or the support VM is the + safer validation surface. + +## Invariants + +- Engine is the sole writer of campaign state. +- OpenWorlds/native app is a thin reader plus `/move` intent submitter. +- `dist/WorldOS.app` is the product surface and release truth. +- Private art stays outside git; evidence routes to `/Volumes/LEXAR/Codex`. +- Deterministic test providers are gated and never silently active in normal + player mode. +- Harness failures use crisp reason buckets; missing evidence never counts as a + pass. diff --git a/qa/SCORECARD.md b/qa/SCORECARD.md index f86bc5ed..0f672d17 100644 --- a/qa/SCORECARD.md +++ b/qa/SCORECARD.md @@ -12,6 +12,9 @@ | Run | Date | app/code SHA | Surface | Provider | Evidence | Result / notes | |---|---|---|---|---|---|---| +| codex-app-headproof-20260601T043909 | 2026-06-01 | 8bd833f | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-headproof-20260601T043909/` | **PASS diagnostic clean-tree trace-clean first-turn + one-move play proof.** Private BG art present; `/app-status` reports build `8bd833f`, live Codex run, writable move sink, Alfira as active player, five enabled actions, and private-art root present; one `/move` accepted and resolved; chat roles `dm, player, dm`; `/session-surface` remained live/actionable; provider trace summary reports zero errors and zero failed tool calls; `app-evidence/manifest.json` exported. Not an RRI verdict. | +| codex-app-traceclean-20260601T042248 | 2026-06-01 | PR #475 worktree (`run.json` build_sha `a72df04`) | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-traceclean-20260601T042248/` | **PASS diagnostic trace-clean first-turn + one-move play proof.** Private BG art present; Alfira seated; `/app-status` reports live Codex run, writable move sink, five enabled actions, and private-art root present; one `/move` accepted and resolved; chat roles `dm, player, dm`; `/session-surface` remained live/actionable; provider trace summary reports zero errors and zero failed tool calls. Not an RRI verdict. | +| codex-app-socialclean-20260601T041604 | 2026-06-01 | PR #475 worktree (`run.json` build_sha `a72df04`) | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-socialclean-20260601T041604/` | **PASS diagnostic gameplay proof with recovered provider noise.** Private BG art present; Alfira seated; one `/move` accepted and resolved; `/session-surface` remained actionable. Provider trace still had two recoverable opening-turn cancellations, so it is preserved as diagnostic evidence only. The follow-up trace-clean run above supersedes it for #479. | | codex-app-final-20260601T033714 | 2026-06-01 | 500c379 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-final-20260601T033714/` | **PASS diagnostic exact-head first-turn + one-move play proof.** Private BG art visible; Alfira seated; `/session-surface` live/actionable with five enabled actions; one `/move` accepted and resolved; chat roles `dm, player, dm`; no rejected `log_event(speaker:null)` calls; mid-session state discovery used `scene_context(campaign_id=...)` with no broad `rg`/`find` state scans. Not an RRI verdict. Remaining provider/tool-contract noise tracked as #479: safety-cancelled setup/social calls still add latency. | | codex-app-short-20260601T022114 | 2026-06-01 | c3dfee6 | `dist/WorldOS.app` local Mac | Codex DM | `/Volumes/LEXAR/Codex/worldos-built-app-playtest/codex-app-short-20260601T022114/` | **PASS diagnostic short playtest.** Private BG art visible; Arka seated; `/session-surface` live/actionable with five enabled actions; two `/move` submissions accepted; chat roles `dm, player, dm, player, dm`; final snapshot set `fist_checkpoint_exposed`. Not an RRI verdict. Finding filed as #476: one recovered `log_event(speaker:null)` validation error added latency/noise on move 2. | diff --git a/qa/export_app_evidence.py b/qa/export_app_evidence.py new file mode 100644 index 00000000..9c450341 --- /dev/null +++ b/qa/export_app_evidence.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Export a read-only WorldOS app evidence bundle. + +The exporter reads the app status endpoint, fetches the projected session surface when +available, and copies small local evidence files named by app-status. It never calls +write endpoints such as /move and never mutates campaign state. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +DEFAULT_OUTPUT_ROOT = Path("/Volumes/LEXAR/Codex") +MAX_HTTP_BYTES = 8 * 1024 * 1024 +MAX_LOCAL_FILE_BYTES = 64 * 1024 * 1024 + + +def utc_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def json_dump(path: Path, payload: Any) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def read_json_bytes(data: bytes, source: str) -> dict[str, Any]: + try: + payload = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"{source} did not return valid JSON: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError(f"{source} returned JSON {type(payload).__name__}, expected object") + return payload + + +def fetch_json(url: str, *, timeout: float = 5.0) -> tuple[dict[str, Any], dict[str, Any]]: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "worldos-app-evidence-exporter/1", + }, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read(MAX_HTTP_BYTES + 1) + if len(body) > MAX_HTTP_BYTES: + raise ValueError(f"{url} response exceeded {MAX_HTTP_BYTES} bytes") + payload = read_json_bytes(body, url) + meta = { + "url": url, + "status": int(getattr(resp, "status", 0) or 0), + "content_type": resp.headers.get("Content-Type", ""), + "bytes": len(body), + } + return payload, meta + + +def bundle_dir_for(out: str) -> Path: + if out: + return Path(out).expanduser() + return DEFAULT_OUTPUT_ROOT / "worldos-app-evidence" / utc_stamp() + + +def source_display(path: Path, bundle: Path) -> str: + try: + return str(path.relative_to(bundle)) + except ValueError: + return str(path) + + +def art_status(app_status: dict[str, Any]) -> dict[str, Any]: + art = app_status.get("art") if isinstance(app_status.get("art"), dict) else {} + present = art.get("private_root_present") + if present is True: + status = "present" + elif present is False: + status = "missing" + else: + status = "unknown" + return { + "repo_root": str(art.get("repo_root") or ""), + "private_root": str(art.get("private_root") or ""), + "private_root_present": present if isinstance(present, bool) else None, + "status": status, + } + + +def build_info(app_status: dict[str, Any]) -> dict[str, str]: + build = app_status.get("build") if isinstance(app_status.get("build"), dict) else {} + return { + "sha": str(build.get("sha") or ""), + "version": str(build.get("version") or ""), + } + + +def local_path_from_status(value: Any) -> Path | None: + if not isinstance(value, str) or not value.strip(): + return None + raw = value.strip() + parsed = urllib.parse.urlparse(raw) + if parsed.scheme and parsed.scheme != "file": + return None + if parsed.scheme == "file": + return Path(urllib.request.url2pathname(parsed.path)) + return Path(raw).expanduser() + + +def copy_local_file(kind: str, value: Any, bundle: Path, gaps: list[dict[str, str]]) -> dict[str, Any] | None: + source = local_path_from_status(value) + if source is None: + if isinstance(value, str) and value.strip(): + gaps.append({ + "source": "local_file", + "kind": kind, + "path": value, + "reason": "not_a_local_file_path", + }) + return None + + try: + resolved = source.resolve(strict=True) + except FileNotFoundError: + gaps.append({ + "source": "local_file", + "kind": kind, + "path": str(source), + "reason": "missing", + }) + return None + except OSError as exc: + gaps.append({ + "source": "local_file", + "kind": kind, + "path": str(source), + "reason": f"unreadable_path: {exc}", + }) + return None + + try: + stat = resolved.stat() + except OSError as exc: + gaps.append({ + "source": "local_file", + "kind": kind, + "path": str(resolved), + "reason": f"stat_failed: {exc}", + }) + return None + if not resolved.is_file(): + gaps.append({ + "source": "local_file", + "kind": kind, + "path": str(resolved), + "reason": "not_a_regular_file", + }) + return None + if stat.st_size > MAX_LOCAL_FILE_BYTES: + gaps.append({ + "source": "local_file", + "kind": kind, + "path": str(resolved), + "reason": f"too_large: {stat.st_size} bytes", + }) + return None + if not os.access(resolved, os.R_OK): + gaps.append({ + "source": "local_file", + "kind": kind, + "path": str(resolved), + "reason": "not_readable", + }) + return None + + suffix = resolved.suffix or ".txt" + dest_dir = bundle / "local-files" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / f"{kind}{suffix}" + shutil.copy2(resolved, dest) + return { + "kind": kind, + "source": str(resolved), + "path": source_display(dest, bundle), + "bytes": stat.st_size, + } + + +def local_file_candidates(app_status: dict[str, Any]) -> list[tuple[str, Any]]: + viewer = app_status.get("viewer") if isinstance(app_status.get("viewer"), dict) else {} + live = app_status.get("live") if isinstance(app_status.get("live"), dict) else {} + return [ + ("chat", viewer.get("chat_path")), + ("moves", live.get("moves_path")), + ("transcript", viewer.get("transcript_path")), + ] + + +def session_surface_url(app_status_url: str, app_status: dict[str, Any]) -> str: + endpoints = app_status.get("endpoints") if isinstance(app_status.get("endpoints"), dict) else {} + endpoint = str(endpoints.get("session_surface") or "/session-surface") + candidate = urllib.parse.urljoin(app_status_url, endpoint) + parsed = urllib.parse.urlparse(candidate) + if parsed.query: + return candidate + live = app_status.get("live") if isinstance(app_status.get("live"), dict) else {} + campaign_id = str(live.get("campaign_id") or "") + if campaign_id: + return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode({"campaign": campaign_id}))) + original = urllib.parse.urlparse(app_status_url) + if original.query: + return urllib.parse.urlunparse(parsed._replace(query=original.query)) + return candidate + + +def find_first_string(payload: Any, keys: tuple[str, ...]) -> str: + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for value in payload.values(): + found = find_first_string(value, keys) + if found: + return found + elif isinstance(payload, list): + for value in payload: + found = find_first_string(value, keys) + if found: + return found + return "" + + +def read_transition_file(path_value: str, bundle: Path, gaps: list[dict[str, str]]) -> dict[str, Any]: + if not path_value: + return {} + source = local_path_from_status(path_value) + if source is None: + gaps.append({ + "source": "transition_file", + "kind": "transition", + "path": path_value, + "reason": "not_a_local_file_path", + }) + return {} + copied = copy_local_file("transition", str(source), bundle, gaps) + if copied is None: + return {} + path = bundle / copied["path"] + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + gaps.append({ + "source": "transition_file", + "kind": "transition", + "path": str(source), + "reason": f"invalid_json: {exc}", + }) + return {"path": copied["path"]} + return { + "path": copied["path"], + "failure_bucket": find_first_string(payload, ("failure_bucket", "failureBucket", "bucket")), + "failure_detail": find_first_string(payload, ("failure_detail", "failureDetail", "detail", "error")), + } + + +def exporter_manifest(args: argparse.Namespace, bundle: Path) -> tuple[dict[str, Any], int]: + gaps: list[dict[str, str]] = [] + sources: dict[str, Any] = {} + copied_files: list[dict[str, Any]] = [] + app_status: dict[str, Any] = {} + exit_code = 0 + + try: + app_status, meta = fetch_json(args.app_status_url) + json_dump(bundle / "app-status.json", app_status) + sources["app_status"] = { + **meta, + "path": "app-status.json", + "ok": True, + } + except (OSError, urllib.error.URLError, ValueError) as exc: + gaps.append({ + "source": "app_status", + "kind": "http_json", + "path": args.app_status_url, + "reason": str(exc), + }) + sources["app_status"] = { + "url": args.app_status_url, + "ok": False, + } + exit_code = 1 + + if app_status: + surface_url = session_surface_url(args.app_status_url, app_status) + try: + session_surface, surface_meta = fetch_json(surface_url) + json_dump(bundle / "session-surface.json", session_surface) + sources["session_surface"] = { + **surface_meta, + "path": "session-surface.json", + "ok": True, + } + except (OSError, urllib.error.URLError, ValueError) as exc: + gaps.append({ + "source": "session_surface", + "kind": "http_json", + "path": surface_url, + "reason": str(exc), + }) + sources["session_surface"] = { + "url": surface_url, + "ok": False, + } + + for kind, value in local_file_candidates(app_status): + copied = copy_local_file(kind, value, bundle, gaps) + if copied is not None: + copied_files.append(copied) + + transition = read_transition_file(args.transition_file, bundle, gaps) + if transition: + sources["transition"] = transition + + live = app_status.get("live") if isinstance(app_status.get("live"), dict) else {} + manifest = { + "schema": "worldos.app-evidence.v1", + "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "app_status_url": args.app_status_url, + "bundle_dir": str(bundle), + "build": build_info(app_status), + "art": art_status(app_status), + "live": { + "campaign_id": str(live.get("campaign_id") or ""), + "attached_campaign_id": str(live.get("attached_campaign_id") or ""), + "run_id": str(live.get("run_id") or ""), + "can_act": bool(live.get("can_act")) if "can_act" in live else None, + "enabled_action_count": live.get("enabled_action_count"), + }, + "sources": sources, + "copied_files": copied_files, + "evidence_gaps": gaps, + } + return manifest, exit_code + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Export a read-only WorldOS app evidence bundle.") + parser.add_argument("--app-status-url", required=True, help="URL for the app /app-status endpoint") + parser.add_argument("--out", default="", help="Evidence bundle directory (default: /Volumes/LEXAR/Codex/worldos-app-evidence/)") + parser.add_argument("--transition-file", default="", help="Optional local JSON file with failure bucket/detail") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv if argv is not None else sys.argv[1:]) + bundle = bundle_dir_for(args.out).resolve() + bundle.mkdir(parents=True, exist_ok=True) + manifest, exit_code = exporter_manifest(args, bundle) + manifest_path = bundle / "manifest.json" + json_dump(manifest_path, manifest) + print(str(manifest_path)) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/qa/test_export_app_evidence.py b/qa/test_export_app_evidence.py new file mode 100644 index 00000000..913ae9ee --- /dev/null +++ b/qa/test_export_app_evidence.py @@ -0,0 +1,131 @@ +import json +import subprocess +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "qa" / "export_app_evidence.py" + + +class EvidenceHandler(BaseHTTPRequestHandler): + app_status = {} + session_surface = {} + + def log_message(self, _fmt: str, *_args: object) -> None: + return + + def _json(self, status: int, payload: dict) -> None: + data = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self) -> None: # noqa: N802 + if self.path.startswith("/app-status"): + self._json(200, self.app_status) + elif self.path.startswith("/session-surface"): + self._json(200, self.session_surface) + else: + self._json(404, {"error": "not found"}) + + +class ExportAppEvidenceTests(unittest.TestCase): + def serve(self, app_status: dict, session_surface: dict) -> tuple[HTTPServer, str]: + handler = type("TestEvidenceHandler", (EvidenceHandler,), {}) + handler.app_status = app_status + handler.session_surface = session_surface + server = HTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, f"http://127.0.0.1:{server.server_port}/app-status?campaign=camp_test" + + def run_exporter(self, out: Path, app_status_url: str) -> tuple[int, str, dict]: + cmd = [ + sys.executable, + str(SCRIPT), + "--app-status-url", + app_status_url, + "--out", + str(out), + ] + proc = subprocess.run(cmd, cwd=ROOT, text=True, capture_output=True, check=False) + manifest = out / "manifest.json" + payload = json.loads(manifest.read_text(encoding="utf-8")) if manifest.exists() else {} + return proc.returncode, proc.stdout + proc.stderr, payload + + def test_creates_manifest_with_status_surface_and_local_evidence_files(self): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + chat = tmp / "chat.jsonl" + moves = tmp / "player_moves.jsonl" + chat.write_text('{"role":"dm","text":"Opening."}\n', encoding="utf-8") + moves.write_text('{"text":"Continue."}\n', encoding="utf-8") + app_status = { + "schema": "worldos.app-status.v1", + "build": {"sha": "abc1234", "version": "v1-test"}, + "viewer": {"chat_path": str(chat), "transcript_path": ""}, + "live": {"moves_path": str(moves), "campaign_id": "camp_test"}, + "art": {"private_root": str(tmp / "art"), "private_root_present": True}, + "endpoints": {"session_surface": "/session-surface"}, + } + session_surface = { + "schema": "worldos.session-surface.v1", + "campaign_id": "camp_test", + "can_act": True, + } + server, url = self.serve(app_status, session_surface) + out = tmp / "bundle" + try: + rc, text, payload = self.run_exporter(out, url) + finally: + server.shutdown() + + self.assertEqual(rc, 0, text) + self.assertEqual(payload["schema"], "worldos.app-evidence.v1") + self.assertEqual(payload["build"], {"sha": "abc1234", "version": "v1-test"}) + self.assertEqual(payload["art"]["private_root_present"], True) + self.assertEqual(payload["sources"]["app_status"]["path"], "app-status.json") + self.assertEqual(payload["sources"]["session_surface"]["path"], "session-surface.json") + copied = {entry["kind"]: entry for entry in payload["copied_files"]} + self.assertEqual(set(copied), {"chat", "moves"}) + self.assertEqual((out / copied["chat"]["path"]).read_text(encoding="utf-8"), chat.read_text(encoding="utf-8")) + self.assertEqual((out / copied["moves"]["path"]).read_text(encoding="utf-8"), moves.read_text(encoding="utf-8")) + self.assertEqual(payload["evidence_gaps"], []) + + def test_missing_optional_local_files_are_recorded_as_evidence_gaps(self): + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + missing_chat = tmp / "missing-chat.jsonl" + missing_moves = tmp / "missing-moves.jsonl" + app_status = { + "schema": "worldos.app-status.v1", + "build": {"sha": "def5678", "version": "v1-gap"}, + "viewer": {"chat_path": str(missing_chat), "transcript_path": ""}, + "live": {"moves_path": str(missing_moves), "campaign_id": "camp_gap"}, + "art": {"private_root": str(tmp / "art"), "private_root_present": False}, + "endpoints": {"session_surface": "/session-surface"}, + } + server, url = self.serve(app_status, {"campaign_id": "camp_gap"}) + out = tmp / "bundle" + try: + rc, text, payload = self.run_exporter(out, url) + finally: + server.shutdown() + + self.assertEqual(rc, 0, text) + self.assertEqual(payload["copied_files"], []) + self.assertEqual(payload["art"]["status"], "missing") + gaps = {(gap["source"], gap["kind"]) for gap in payload["evidence_gaps"]} + self.assertIn(("local_file", "chat"), gaps) + self.assertIn(("local_file", "moves"), gaps) + + +if __name__ == "__main__": + unittest.main() diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index 39988262..c7301d5c 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -61,6 +61,10 @@ def test_built_app_playtest_can_keep_minted_backend_for_manual_gameplay(self): self.assertIn("PART_A_KEPT_BACKEND", harness) self.assertIn("first_turn_ready", harness) self.assertIn("waiting for first-turn readiness", harness) + self.assertIn("/app-status", harness) + self.assertIn("app-status.launcher.json", harness) + self.assertIn("app-status.minted.json", harness) + self.assertIn("app_status_after", harness) self.assertIn('.actionModel.actor.name', harness) self.assertIn('play_party.sh .* $minted_run', harness) self.assertIn('play.sh .* $minted_run', harness) @@ -81,6 +85,5 @@ def test_provider_viewer_stays_attached_during_native_restarts(self): ) self.assertIn('launchMessage = "Provider session active"', root_view) - if __name__ == "__main__": unittest.main() diff --git a/qa/ui_playtest_app.sh b/qa/ui_playtest_app.sh index 97bfbf1c..a178e7ea 100755 --- a/qa/ui_playtest_app.sh +++ b/qa/ui_playtest_app.sh @@ -245,6 +245,8 @@ PY [ "$ready" = "1" ] || { a_log "[A] launcher viewer never came up (discovered port='${launcher_port:-none}')"; PART_A_RESULT="no_launcher"; return 1; } app_pid_for_bundle "$APP_BUNDLE" >/dev/null 2>&1 || { a_log "[A] target WorldOSApp bundle is not running"; PART_A_RESULT="app_not_running"; return 1; } a_log "[A] launcher viewer ready on $launcher_port; can_act(before)=$(curl -s "http://127.0.0.1:$launcher_port/session-surface" | jq -c '{can_act,is_live_view,live}' 2>/dev/null)" + curl -s --max-time 3 "http://127.0.0.1:$launcher_port/app-status" \ + | jq . > "$NATIVE_DIR/app-status.launcher.json" 2>/dev/null || printf '{}\n' > "$NATIVE_DIR/app-status.launcher.json" # BEFORE screenshot + baseline play-state set (so a NEW run dir is detectable post-click). screenshot "$NATIVE_DIR/before.png" && a_log "[A] before.png captured" || a_log "[A] before.png deferred to orchestrator" @@ -311,6 +313,17 @@ PY PART_A_MINTED_PORT="$minted_port"; PART_A_RUNDIR="$minted_run" local surf_final="{}" [ -n "$minted_port" ] && surf_final="$(curl -s "http://127.0.0.1:$minted_port/session-surface" 2>/dev/null | jq -c '{can_act,is_live_view,live,campaignId,enabledActions}' 2>/dev/null || echo '{}')" + local app_status_final="{}" + if [ -n "$minted_port" ]; then + if curl -s --max-time 3 "http://127.0.0.1:$minted_port/app-status" \ + | jq . > "$NATIVE_DIR/app-status.minted.json" 2>/dev/null; then + app_status_final="$(jq -c . "$NATIVE_DIR/app-status.minted.json" 2>/dev/null || echo '{}')" + else + printf '{}\n' > "$NATIVE_DIR/app-status.minted.json" + fi + else + printf '{}\n' > "$NATIVE_DIR/app-status.minted.json" + fi if [ "$KEEP_MINTED_BACKEND" = "1" ] && [ -n "$minted_run" ] && [ -n "$minted_port" ]; then a_log "[A] keep-alive proof: waiting for first-turn readiness (actor + enabled actions + narration)…" @@ -324,6 +337,9 @@ PY if [ -n "$actor" ] && [ "${enabled_count:-0}" -gt 0 ] && { [ "${narration_count:-0}" -gt 0 ] || [ "${chat_lines:-0}" -gt 0 ]; }; then PART_A_FIRST_TURN_READY="true" surf_final="$(printf '%s' "$ready_surf" | jq -c '{can_act,is_live_view,live,campaignId,enabledActions,actor:.actionModel.actor,recentEvents}' 2>/dev/null || printf '%s' "$ready_surf")" + curl -s --max-time 3 "http://127.0.0.1:$minted_port/app-status" \ + | jq . > "$NATIVE_DIR/app-status.minted.json" 2>/dev/null || true + app_status_final="$(jq -c . "$NATIVE_DIR/app-status.minted.json" 2>/dev/null || echo '{}')" a_log "[A] keep-alive proof ready: actor=$actor enabled=$enabled_count narration_events=${narration_count:-0} chat_lines=${chat_lines:-0}." break fi @@ -364,11 +380,13 @@ PY python3 - "$NATIVE_DIR/transition.json" "$PART_A_RESULT" "$BUILD_SHA" "$VERSION" \ "${minted_run:-}" "${minted_port:-}" "$can_act" "$surf_final" \ - "$PART_A_KEPT_BACKEND" "$PART_A_FIRST_TURN_READY" <<'PY' + "$PART_A_KEPT_BACKEND" "$PART_A_FIRST_TURN_READY" "$app_status_final" <<'PY' import json, sys, datetime -out, result, sha, ver, run, port, can_act, surf, kept, first_turn_ready = sys.argv[1:11] +out, result, sha, ver, run, port, can_act, surf, kept, first_turn_ready, app_status = sys.argv[1:12] try: surf_obj = json.loads(surf) except Exception: surf_obj = {"raw": surf} +try: app_status_obj = json.loads(app_status) +except Exception: app_status_obj = {"raw": app_status} json.dump({ "gate": "native_transition_356", "result": result, "build_sha": sha, "version": ver, @@ -377,6 +395,9 @@ json.dump({ "first_turn_ready": first_turn_ready == "true", "can_act_after_click": can_act == "true", "session_surface_after": surf_obj, + "app_status_after": app_status_obj, + "app_status_launcher_json": "native/app-status.launcher.json", + "app_status_minted_json": "native/app-status.minted.json", "before_png": "native/before.png", "after_png": "native/after.png", "at": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z", }, open(out, "w"), indent=2) diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index 91c35a00..537ca57a 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -252,29 +252,34 @@ HERO_PC_ID="" HERO_PC_NAME="" HERO_PC_RACE="" HERO_PC_CLASS="" -if [ -n "${CLAWDND_PLAY_HERO:-}" ]; then - HERO_SEED_JSON="$(CLAWDND_STATE_DIR="$RUN_DIR" WORLDOS_STATE_DIR="$RUN_DIR" uv run --directory "$ROOT/servers/engine" python - "$CLAWDND_WORLD" "$CLAWDND_PLAY_HERO" <<'PY' +HERO_SEED_JSON="$(CLAWDND_STATE_DIR="$RUN_DIR" WORLDOS_STATE_DIR="$RUN_DIR" uv run --directory "$ROOT/servers/engine" python - "$CLAWDND_WORLD" "${CLAWDND_PLAY_HERO:-}" "${CLAWDND_PLAY_CANON_HERO:-Alfira}" <<'PY' import json import sys import server -world, spec_raw = sys.argv[1], sys.argv[2] -try: - spec = json.loads(spec_raw) -except json.JSONDecodeError as exc: - sys.stderr.write(f"native hero spec is not valid JSON: {exc}\n") - sys.exit(1) -if not isinstance(spec, dict): - sys.stderr.write("native hero spec must be a JSON object\n") - sys.exit(1) -if not spec.get("canon"): - sys.stderr.write("Codex DM provider currently supports native roster canon hero specs only\n") - sys.exit(1) -canon_name = str(spec.get("name") or "").strip() -if not canon_name: - sys.stderr.write("native roster canon hero spec is missing name\n") - sys.exit(1) +world, spec_raw, fallback_name = sys.argv[1], sys.argv[2], sys.argv[3] +explicit = False +canon_name = "" +if spec_raw.strip(): + explicit = True + try: + spec = json.loads(spec_raw) + except json.JSONDecodeError as exc: + sys.stderr.write(f"native hero spec is not valid JSON: {exc}\n") + sys.exit(1) + if not isinstance(spec, dict): + sys.stderr.write("native hero spec must be a JSON object\n") + sys.exit(1) + if not spec.get("canon"): + sys.stderr.write("Codex DM provider currently supports native roster canon hero specs only\n") + sys.exit(1) + canon_name = str(spec.get("name") or "").strip() + if not canon_name: + sys.stderr.write("native roster canon hero spec is missing name\n") + sys.exit(1) +else: + canon_name = fallback_name.strip() or "Alfira" started = server.start_world(world) camp = started.get("campaign_id") if isinstance(started, dict) else "" @@ -282,9 +287,36 @@ if not camp: sys.stderr.write("start_world did not return a campaign_id\n") sys.exit(1) server.start_session(camp, title=f"Codex DM: {canon_name}") -rec = server.load_canon_character(camp, canon_name, kind="player", add_to_party=True) + +names = [canon_name] +if not explicit: + try: + roster = server.list_canon_characters(camp, playable_only=True).get("available") or [] + except Exception: + roster = [] + for row in roster: + if not isinstance(row, dict): + continue + name = str(row.get("name") or "").strip() + if name and name not in names: + names.append(name) + +rec = {} +errors = [] +for name in names: + try: + candidate = server.load_canon_character(camp, name, kind="player", add_to_party=True) + except Exception as exc: + errors.append(f"{name}: {exc}") + continue + if isinstance(candidate, dict) and not candidate.get("error") and candidate.get("id"): + rec = candidate + canon_name = name + break + errors.append(f"{name}: {(candidate or {}).get('error') if isinstance(candidate, dict) else candidate}") + if not isinstance(rec, dict) or rec.get("error"): - sys.stderr.write("canon pickup failed: " + str((rec or {}).get("error") if isinstance(rec, dict) else rec) + "\n") + sys.stderr.write("canon pickup failed: " + "; ".join(errors[:5]) + "\n") sys.exit(1) print(json.dumps({ @@ -297,15 +329,14 @@ print(json.dumps({ }, })) PY -)" || fail "native-selected hero pre-seed failed" - HERO_CAMP="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.campaign_id // ""')" - HERO_PC_ID="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.id // ""')" - HERO_PC_NAME="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.name // ""')" - HERO_PC_RACE="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.race // ""')" - HERO_PC_CLASS="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.class // ""')" - [ -n "$HERO_CAMP" ] || fail "native-selected hero pre-seed returned no campaign" - echo "[codex-dm-provider] seeded native-selected hero: $HERO_PC_NAME ($HERO_PC_RACE $HERO_PC_CLASS) in campaign $HERO_CAMP" -fi +)" || fail "solo player pre-seed failed" +HERO_CAMP="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.campaign_id // ""')" +HERO_PC_ID="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.id // ""')" +HERO_PC_NAME="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.name // ""')" +HERO_PC_RACE="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.race // ""')" +HERO_PC_CLASS="$(printf '%s' "$HERO_SEED_JSON" | jq -r '.pc.class // ""')" +[ -n "$HERO_CAMP" ] || fail "solo player pre-seed returned no campaign" +echo "[codex-dm-provider] seeded solo player: $HERO_PC_NAME ($HERO_PC_RACE $HERO_PC_CLASS) in campaign $HERO_CAMP" chatlog() { python3 - "$CHAT" "$1" "$2" <<'PY' @@ -359,6 +390,11 @@ codex_dm_turn() { LOG_EVENT_TOOL_RULE="Tool argument rule: for log_event narration, omit the speaker argument entirely. For dialogue, pass a real non-empty character id or name. Never pass JSON null for speaker or any optional string field." STATE_DISCOVERY_RULE="State discovery rule: after reading skills/dungeon-master/SKILL.md, use clawdnd-engine/clawdnd-rules MCP tools for live game state. Do not use shell commands, rg, find, or filesystem reads to discover campaign state." +STARTUP_MUTATION_RULE="Startup mutation rule: the wrapper has already seated the one player before you are called. Before the first player-facing narration, do not call start_world, start_session, start_character, load_canon_character, create_character, or recruit_companion. Introduce scene NPCs in narration first; create or load a tracked NPC only after the player engages them." +SOCIAL_CHECK_TARGET_RULE="Social check target rule: call social_check only when scene_context already shows a real tracked npc_id for the target. Do not call load_canon_character or create_character solely to manufacture a social-check target during the same turn. If the target is not already tracked, do not use persuasion, deception, intimidation, or another attitude-moving social skill. Use a non-attitude skill_check such as investigation or perception for what the player can infer, then narrate the scene-local response; persist a new NPC later only when the player keeps engaging them." +RULES_LOOKUP_RULE="Rules lookup rule: during the opening turn, do not call lookup_class or other rules lookups just to restate the pre-seated player's class/race; get_state already includes enough player-facing identity for the opener. Use clawdnd-rules only when resolving an actual rule, spell, item, condition, or monster question." +PARLEY_TOOL_RULE="Parley tool rule: when using generate_parley_options, pass an explicit skills array such as persuasion, insight, performance, intimidation, deception. Do not rely on include_alignment or an implicit 'any' skill." +PERSIST_BEAT_RULE="Persist beat rule: do not call persist_beat during the opening turn. Opening state is already logged through log_event; persist only after at least one real player move has been resolved. When calling persist_beat with memories, each memory must be an object with character_id and fact fields. Do not pass memory strings." if [ -n "${CLAWDND_PLAY_COMPANIONS//[[:space:]]/}" ]; then COMPANION_TOOL_RULE="Companion rule: only add companions named by CLAWDND_PLAY_COMPANIONS (${CLAWDND_PLAY_COMPANIONS}). Do not add any other companion to the party." else @@ -400,6 +436,11 @@ Before acting, read skills/dungeon-master/SKILL.md and follow its live-world con $LOG_EVENT_TOOL_RULE $STATE_DISCOVERY_RULE +$STARTUP_MUTATION_RULE +$SOCIAL_CHECK_TARGET_RULE +$RULES_LOOKUP_RULE +$PARLEY_TOOL_RULE +$PERSIST_BEAT_RULE $COMPANION_TOOL_RULE Native-selected canon hero already seated: @@ -422,6 +463,11 @@ Before acting, read skills/dungeon-master/SKILL.md and follow its live-world con $LOG_EVENT_TOOL_RULE $STATE_DISCOVERY_RULE +$STARTUP_MUTATION_RULE +$SOCIAL_CHECK_TARGET_RULE +$RULES_LOOKUP_RULE +$PARLEY_TOOL_RULE +$PERSIST_BEAT_RULE $COMPANION_TOOL_RULE Start a live solo session: @@ -461,6 +507,10 @@ while true; do $LOG_EVENT_TOOL_RULE $STATE_DISCOVERY_RULE $CAMPAIGN_TOOL_HINT +$SOCIAL_CHECK_TARGET_RULE +$RULES_LOOKUP_RULE +$PARLEY_TOOL_RULE +$PERSIST_BEAT_RULE $COMPANION_TOOL_RULE Player move: diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index c4b89878..39df1e50 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -175,8 +175,10 @@ def test_codex_dm_wrapper_honors_native_selected_hero(): source = DM_SCRIPT.read_text(encoding="utf-8") assert "CLAWDND_PLAY_HERO" in source - assert 'load_canon_character(camp, canon_name, kind="player", add_to_party=True)' in source + assert "CLAWDND_PLAY_CANON_HERO" in source + assert 'server.load_canon_character(camp, name, kind="player", add_to_party=True)' in source assert "Native-selected canon hero already seated" in source + assert "seeded solo player" in source def test_codex_dm_wrapper_forbids_null_speaker_arguments(): @@ -206,6 +208,45 @@ def test_codex_dm_wrapper_forbids_unconfigured_solo_companions(): assert "only add companions named by CLAWDND_PLAY_COMPANIONS" in source +def test_codex_dm_wrapper_constrains_startup_roster_mutation(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "STARTUP_MUTATION_RULE" in source + assert "the wrapper has already seated the one player" in source + assert "Before the first player-facing narration" in source + assert "load_canon_character, create_character, or recruit_companion" in source + assert "do not call start_world, start_session, start_character, load_canon_character" in source + assert source.count("$STARTUP_MUTATION_RULE") == 2 + + +def test_codex_dm_wrapper_requires_tracked_social_targets(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "SOCIAL_CHECK_TARGET_RULE" in source + assert "call social_check only when scene_context already shows a real tracked npc_id" in source + assert "Do not call load_canon_character or create_character solely to manufacture" in source + assert "do not use persuasion, deception, intimidation" in source + assert "Use a non-attitude skill_check such as investigation or perception" in source + assert source.count("$SOCIAL_CHECK_TARGET_RULE") == 3 + + +def test_codex_dm_wrapper_avoids_noisy_provider_tool_retries(): + source = DM_SCRIPT.read_text(encoding="utf-8") + + assert "RULES_LOOKUP_RULE" in source + assert "do not call lookup_class" in source + assert "PARLEY_TOOL_RULE" in source + assert "pass an explicit skills array" in source + assert "Do not rely on include_alignment" in source + assert "PERSIST_BEAT_RULE" in source + assert "do not call persist_beat during the opening turn" in source + assert "persist only after at least one real player move" in source + assert "each memory must be an object with character_id and fact fields" in source + assert source.count("$RULES_LOOKUP_RULE") == 3 + assert source.count("$PARLEY_TOOL_RULE") == 3 + assert source.count("$PERSIST_BEAT_RULE") == 3 + + def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() @@ -280,7 +321,7 @@ def test_codex_dm_wrapper_processes_moves_submitted_during_opening(tmp_path): printf 'Opening narration from fake Codex.' > "$last" printf '{"type":"result","result":"Opening narration from fake Codex."}\\n' else - printf '%s' "$prompt" | grep -q 'Live campaign_id: "camp_fake"' || { + printf '%s' "$prompt" | grep -q 'Live campaign_id: "camp_' || { echo "missing live campaign hint" >&2 exit 8 } diff --git a/viewer/server.py b/viewer/server.py index debf47e3..030bea2e 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -5329,6 +5329,133 @@ def _viewer_config() -> dict: } +def _file_line_count(path: str) -> int: + if not path: + return 0 + try: + return sum(1 for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()) + except OSError: + return 0 + + +def _repo_build_sha() -> str: + env = env_var("BUILD_SHA", "") + if env and env.strip(): + return env.strip() + try: + proc = subprocess.run( + ["git", "-C", str(_REPO_ROOT), "rev-parse", "--short", "HEAD"], + capture_output=True, + check=False, + text=True, + timeout=1, + ) + except Exception: + return "unknown" + return (proc.stdout or "").strip() or "unknown" + + +def _repo_version() -> str: + env = env_var("APP_VERSION", "") + if env and env.strip(): + return env.strip() + version_file = _REPO_ROOT / "VERSION" + try: + if version_file.is_file(): + return version_file.read_text(encoding="utf-8").strip() or "unknown" + except OSError: + pass + return "unknown" + + +def _move_run_id(dest: Path | None) -> str: + if dest is not None and dest.name == "player_moves.jsonl": + return dest.parent.name + return "" + + +def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign_id: str, + transcript_path: str, chat_path: str) -> dict: + """Machine-readable app/test harness truth for agents. + + This is a read-only probe over the same viewer facts the UI already uses. It is intentionally + small and stable: harnesses can ask which campaign/run/provider/art root they are actually + driving before spending model time or trusting a screenshot. + """ + live = _live_play() + moves = _moves_path() + raw_snap = _read_snapshot(viewed_campaign_id) if viewed_campaign_id else {} + if not isinstance(raw_snap, dict): + raw_snap = {} + is_live_view = bool(live and viewed_campaign_id and viewed_campaign_id == attached_campaign_id) + surface = build_session_surface( + raw_snap, + campaign_id=viewed_campaign_id, + live=live, + is_live_view=is_live_view, + recent_events=_session_event_tail(viewed_campaign_id) if viewed_campaign_id else [], + ) + enabled_actions = [ + str(action.get("id") or "") + for action in surface.get("enabledActions", []) + if isinstance(action, dict) and action.get("id") + ] + actor = (surface.get("actionModel") or {}).get("actor") or {} + art_root = _ingested_images_root() + state_root = _state_dir() + return { + "ok": True, + "schema": "worldos.app-status.v1", + "surface": "openworlds", + "state_authority": "engine", + "write_lane": "/move", + "build": { + "sha": _repo_build_sha(), + "version": _repo_version(), + }, + "viewer": { + "port": int(port), + "repo_root": _resolved(_REPO_ROOT), + "state_root": _resolved(state_root), + "provider": env_var("PROVIDER", "") or "", + "transcript_path": transcript_path, + "chat_path": chat_path, + "chat_lines": _file_line_count(chat_path), + }, + "art": { + "repo_root": _resolved(_art_repo_root()), + "private_root": _resolved(art_root), + "private_root_present": art_root.is_dir(), + }, + "live": { + "attached_campaign_id": attached_campaign_id, + "campaign_id": viewed_campaign_id, + "active_session_id": str(raw_snap.get("active_session_id") or ""), + "run_id": _move_run_id(moves), + "moves_path": _resolved(moves) if moves is not None else "", + "moves_writable": bool(live), + "is_live_view": is_live_view, + "can_act": bool(surface.get("can_act")), + "actor": { + "id": str(actor.get("id") or ""), + "name": str(actor.get("name") or ""), + "kind": str(actor.get("kind") or ""), + }, + "enabled_action_ids": enabled_actions, + "enabled_action_count": len(enabled_actions), + }, + "endpoints": { + "app_status": "/app-status", + "session_surface": "/session-surface", + "campaign_catalog": "/openworlds/campaigns.json", + "move": "/move", + "chat": "/chat", + "activity": "/activity", + "image": "/image?scope=", + }, + } + + def _read_events(campaign_id: str, since: int) -> tuple[list[dict], int]: """Return (new story entries after line `since`, new line count). Drops a trailing partial line defensively (append-only writes can exceed PIPE_BUF).""" @@ -5743,6 +5870,7 @@ def _openworlds_config() -> dict: "mode": "viewer-read-model", "api_base": "", "campaign_catalog": "/openworlds/campaigns.json", + "app_status": "/app-status", "session_surface": "/session-surface", "combat_surface": "/combat-surface", "atlas_surface": "/atlas-surface", @@ -6010,6 +6138,16 @@ def do_GET(self) -> None: # noqa: N802 self._send(200, html, "text/html; charset=utf-8") elif route == "/openworlds/config.json": self._json(_openworlds_config()) + elif route in ("/app-status", "/__worldos/app-status.json"): + qs = parse_qs(parsed.query) + viewed = self._view_campaign(qs) + self._json(_app_status_payload( + port=int(self.server.server_address[1]), + attached_campaign_id=self.campaign_id, + viewed_campaign_id=viewed, + transcript_path=self.transcript_path, + chat_path=self.chat_path, + )) elif route == "/openworlds/campaigns.json": self._json(_openworlds_campaigns(self.campaign_id)) elif route == "/session-surface": diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index b94af3e3..e89a11ba 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -30,10 +30,14 @@ def setUp(self): self._old_clawdnd_art_repo_root = os.environ.get("CLAWDND_ART_REPO_ROOT") self._old_worldos_repo_root = os.environ.get("WORLDOS_REPO_ROOT") self._old_clawdnd_repo_root = os.environ.get("CLAWDND_REPO_ROOT") + self._old_worldos_player_moves = os.environ.get("WORLDOS_PLAYER_MOVES") + self._old_clawdnd_player_moves = os.environ.get("CLAWDND_PLAYER_MOVES") os.environ.pop("WORLDOS_ART_REPO_ROOT", None) os.environ.pop("CLAWDND_ART_REPO_ROOT", None) os.environ.pop("WORLDOS_REPO_ROOT", None) os.environ.pop("CLAWDND_REPO_ROOT", None) + os.environ.pop("WORLDOS_PLAYER_MOVES", None) + os.environ.pop("CLAWDND_PLAYER_MOVES", None) self._old_here = server._HERE os.environ["CLAWDND_STATE_DIR"] = str(self._tmp) _QuietHandler.campaign_id = "" @@ -69,6 +73,14 @@ def tearDown(self): os.environ.pop("CLAWDND_REPO_ROOT", None) else: os.environ["CLAWDND_REPO_ROOT"] = self._old_clawdnd_repo_root + if self._old_worldos_player_moves is None: + os.environ.pop("WORLDOS_PLAYER_MOVES", None) + else: + os.environ["WORLDOS_PLAYER_MOVES"] = self._old_worldos_player_moves + if self._old_clawdnd_player_moves is None: + os.environ.pop("CLAWDND_PLAYER_MOVES", None) + else: + os.environ["CLAWDND_PLAYER_MOVES"] = self._old_clawdnd_player_moves server._HERE = self._old_here def _get(self, path: str) -> tuple[int, str, bytes]: @@ -127,9 +139,63 @@ def test_openworlds_config_is_browser_safe_metadata(self): self.assertEqual(config["state_authority"], "engine") self.assertEqual(config["write_lane"], "/move") self.assertEqual(config["campaign_catalog"], "/openworlds/campaigns.json") + self.assertEqual(config["app_status"], "/app-status") self.assertFalse(config["demo_data"]) self.assertTrue(config["demo_data_fallback"]) + def test_app_status_route_exposes_agent_probe_contract(self): + campaign_dir = self._tmp / "campaigns" / "camp_live" + self._write_snapshot( + campaign_dir, + { + "id": "camp_live", + "title": "Live Probe Save", + "active_session_id": "session_live", + "world_id": "baldurs-gate", + "party": ["hero"], + "characters": { + "hero": { + "id": "hero", + "name": "Probe Hero", + "kind": "player", + "current_hp": 8, + "max_hp": 8, + }, + }, + }, + ) + moves = self._tmp / "play-123" / "player_moves.jsonl" + moves.parent.mkdir() + moves.write_text("", encoding="utf-8") + chat = self._tmp / "play-123" / "chat.jsonl" + chat.write_text('{"role":"dm","text":"Opening."}\n', encoding="utf-8") + os.environ["CLAWDND_PLAYER_MOVES"] = str(moves) + _QuietHandler.campaign_id = "camp_live" + _QuietHandler.chat_path = str(chat) + + status, ctype, body = self._get("/app-status?campaign=camp_live") + + self.assertEqual(status, 200) + self.assertIn("application/json", ctype) + payload = json.loads(body.decode("utf-8")) + self.assertEqual(payload["schema"], "worldos.app-status.v1") + self.assertEqual(payload["state_authority"], "engine") + self.assertEqual(payload["write_lane"], "/move") + self.assertEqual(payload["viewer"]["port"], self._port) + self.assertEqual(payload["viewer"]["chat_lines"], 1) + self.assertTrue(payload["art"]["private_root"].endswith("content/worlds/_private")) + self.assertEqual(payload["live"]["attached_campaign_id"], "camp_live") + self.assertEqual(payload["live"]["campaign_id"], "camp_live") + self.assertEqual(payload["live"]["active_session_id"], "session_live") + self.assertEqual(payload["live"]["run_id"], "play-123") + self.assertEqual(payload["live"]["moves_path"], server._resolved(moves)) + self.assertTrue(payload["live"]["moves_writable"]) + self.assertTrue(payload["live"]["is_live_view"]) + self.assertTrue(payload["live"]["can_act"]) + self.assertEqual(payload["live"]["actor"]["name"], "Probe Hero") + self.assertIn("continue", payload["live"]["enabled_action_ids"]) + self.assertEqual(payload["endpoints"]["session_surface"], "/session-surface") + def test_openworlds_static_assets_are_same_origin_and_local(self): status, ctype, body = self._get("/openworlds/vendor/google-fonts.css")