From c48dac69a6ba18691b126ce458beefedd8791c42 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 6 May 2026 14:33:58 +0100 Subject: [PATCH 1/5] Bridge Ghostlight scene initiative to Epiphany heartbeat --- .../voidbot-routine-adoption-plan.md | 9 + notes/epiphany-intrusion-audit.md | 16 + notes/fresh-workspace-handoff.md | 25 +- package.json | 3 + schemas/initiative-schedule.schema.json | 47 +++ state/evidence.jsonl | 1 + state/map.yaml | 6 +- tools/ghostlight_epiphany_heartbeat.mjs | 313 ++++++++++++++++++ tools/validate_initiative_schedules.py | 17 + 9 files changed, 427 insertions(+), 10 deletions(-) create mode 100644 notes/epiphany-intrusion-audit.md create mode 100644 tools/ghostlight_epiphany_heartbeat.mjs diff --git a/docs/architecture/voidbot-routine-adoption-plan.md b/docs/architecture/voidbot-routine-adoption-plan.md index 472a211..81f9880 100644 --- a/docs/architecture/voidbot-routine-adoption-plan.md +++ b/docs/architecture/voidbot-routine-adoption-plan.md @@ -51,6 +51,15 @@ heartbeat initiative -> write canonical MessagePack + JSON working projection ``` +Epiphany now owns the first shared Rust heartbeat spine. Ghostlight should call +that spine through `tools/ghostlight_epiphany_heartbeat.mjs` instead of growing a +parallel scheduler. The Ghostlight-side command derives scene participants from +the existing initial/agent-state fixtures, initializes a typed MessagePack +`ghostlight-scene` heartbeat store, and asks Epiphany to emit +`ghostlight.initiative_schedule.v0` receipts with `scene_turn` actions. Those +receipts select the actor and local-turn machinery; responder, appraisal, and +mutation organs still own the actual narrative action. + ## Persistence Shape Ghostlight can start with the CultCacheTS contract directly for Node-side tools, diff --git a/notes/epiphany-intrusion-audit.md b/notes/epiphany-intrusion-audit.md new file mode 100644 index 0000000..2f73c4c --- /dev/null +++ b/notes/epiphany-intrusion-audit.md @@ -0,0 +1,16 @@ +# Epiphany Intrusion Audit + +EpiphanyAgent touched this workspace to add the first Ghostlight-facing bridge +to Epiphany's Rust heartbeat initiative store. + +Scope: + +- no scene fixtures were rewritten +- no accepted narrative artifacts were mutated +- Ghostlight receives a command wrapper that invokes Epiphany's shared Rust + heartbeat scheduler for scene participants +- initiative schema/validation now accepts scheduler-level `scene_turn` + receipts and arena metadata emitted by that shared spine + +Resident agents should treat this as an audited external intervention, not as +their own invisible local work. diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 70bd718..3f3869f 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -77,6 +77,9 @@ The active path is a source-grounded branching-scene data loop: - Initiative schedule schema: `schemas/initiative-schedule.schema.json` - Training-loop validator: `npm run training-loop:validate` - Initiative schedule validator: `npm run initiative:validate` +- Epiphany-backed scene heartbeat init: `npm run heartbeat:init` +- Epiphany-backed scene heartbeat tick/status: + `npm run heartbeat:tick` / `npm run heartbeat:status` - Prompt folder: `prompts/` - Corpus coverage ledger: `state/corpus-coverage.json` - Corpus coverage command: `npm run coverage:status` @@ -207,6 +210,13 @@ schedule artifact. The scheduler is mechanical: it selects eligible actors from initiative speed, next-ready time, load, status, action recovery, and reaction windows. It does not replace participant appraisal. Everyone affected by an event still updates before the scheduler chooses the next projected actor. +The first live scheduler spine now lives in Epiphany's Rust +`epiphany-heartbeat-store`. Ghostlight calls it through +`tools/ghostlight_epiphany_heartbeat.mjs`, which derives participants from the +existing initial/agent-state fixtures, initializes a typed MessagePack +`ghostlight-scene` store, ticks `scene_turn` receipts, and can write a JSON +initiative projection for review/validation. This is an audited external bridge, +not a second Ghostlight scheduler in matching costume. Branch compiler seam correction: `lucent-hostage-feed.branch-and-fold.v0.ink` is the first meta-coordinator authored branch-and-fold draft for exploring the @@ -322,13 +332,14 @@ steering surfaces unless the user explicitly asks for archaeology. ## Current Next Action -Choose the next bounded move: backfill Corvid/NeuroSyn room-scale lore gaps, -convert the Corvid fixture into a true scene-loop receipt chain, backfill the -useful Lucent lore gaps into AetheriaLore, build a visual/scene-set pass for the -Lucent IF, or move to the next coverage fixture. Current accepted full-fixture -coverage is still empty; Pallas, Lucent, and Corvid derivatives are draft rows -with per-organ training readiness or reference-only value, not accepted broad -corpus fixtures. +For live initiative selection, use the Epiphany-backed heartbeat bridge before +inventing any local scheduler. Then choose the next bounded content move: +backfill Corvid/NeuroSyn room-scale lore gaps, convert the Corvid fixture into a +true scene-loop receipt chain, backfill the useful Lucent lore gaps into +AetheriaLore, build a visual/scene-set pass for the Lucent IF, or move to the +next coverage fixture. Current accepted full-fixture coverage is still empty; +Pallas, Lucent, and Corvid derivatives are draft rows with per-organ training +readiness or reference-only value, not accepted broad corpus fixtures. ## Warnings diff --git a/package.json b/package.json index 2eb32d3..96e90eb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "responder-outputs:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_responder_outputs.py", "training-loop:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_training_loop_artifacts.py", "initiative:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_initiative_schedules.py", + "heartbeat:init": "node .\\tools\\ghostlight_epiphany_heartbeat.mjs init-scene", + "heartbeat:tick": "node .\\tools\\ghostlight_epiphany_heartbeat.mjs tick-scene", + "heartbeat:status": "node .\\tools\\ghostlight_epiphany_heartbeat.mjs status", "ink:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_ink_examples.py", "images:count": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\scripts\\count_visual_images.py", "coverage:status": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_corpus_coverage.py" diff --git a/schemas/initiative-schedule.schema.json b/schemas/initiative-schedule.schema.json index 59e06f3..786c2b3 100644 --- a/schemas/initiative-schedule.schema.json +++ b/schemas/initiative-schedule.schema.json @@ -162,6 +162,28 @@ "constraints" ], "properties": { + "arena": { + "enum": [ + "scene", + "maintenance" + ] + }, + "participant_kind": { + "enum": [ + "character", + "agent" + ] + }, + "role_id": { + "type": "string", + "minLength": 1 + }, + "pending_turn": { + "type": [ + "object", + "null" + ] + }, "agent_id": { "type": "string", "minLength": 1 @@ -226,6 +248,18 @@ "local_affordance_basis" ], "properties": { + "arena": { + "enum": [ + "scene", + "maintenance" + ] + }, + "participant_kind": { + "enum": [ + "character", + "agent" + ] + }, "action_id": { "type": "string", "minLength": 1 @@ -236,6 +270,7 @@ }, "action_type": { "enum": [ + "scene_turn", "speak", "silence", "move", @@ -356,6 +391,18 @@ "eligible_for_reaction" ], "properties": { + "arena": { + "enum": [ + "scene", + "maintenance" + ] + }, + "participant_kind": { + "enum": [ + "character", + "agent" + ] + }, "agent_id": { "type": "string", "minLength": 1 diff --git a/state/evidence.jsonl b/state/evidence.jsonl index e18cf4b..99c2208 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -66,3 +66,4 @@ {"note": "Ghostlight began migrating machine-managed persistent state behind cultcache-py: branch and evidence records now route through a registered CultCache seam with readable JSON exports kept for diffability and compatibility.", "status": "accepted", "ts": "2026-05-06T03:46:41+00:00", "type": "architecture"} {"note": "CultCache wire contract clarified: DatabaseEntry payloads must be MessagePack arrays indexed by explicit field keys with nil gaps for reserved/deleted slots; cultcache-py now exposes define_database_entry_type for that contract and CultLib generator was patched to preserve sparse key slots.", "status": "accepted", "ts": "2026-05-06T03:57:10+00:00", "type": "architecture"} {"ts": "2026-05-06T04:49:20+00:00", "type": "architecture", "status": "accepted", "note": "Ghostlight agent-state schema is now the first shared cross-runtime payload contract. CultNet clients should carry ghostlight.agent_state.v0 unchanged inside typed document envelopes, with CultLib-style auth/session semantics handled by the transport layer rather than bespoke per-repo glue."} +{"ts": "2026-05-06T13:32:30+00:00", "type": "implementation", "status": "ok", "note": "Ghostlight now has a local Epiphany heartbeat bridge: tools/ghostlight_epiphany_heartbeat.mjs derives scene participants from existing initial/agent-state fixtures, invokes Epiphany's Rust epiphany-heartbeat-store ghostlight-scene profile, writes typed MessagePack scene heartbeat state, and emits validator-accepted scene_turn initiative receipts. This replaces the need for a parallel Ghostlight scheduler for the first scene-heartbeat slice."} diff --git a/state/map.yaml b/state/map.yaml index f1fc91c..8858fa2 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -86,7 +86,7 @@ live_architecture: - Actions carry scale, recovery, initiative cost, interruptibility, commitment, and local affordance basis. - Reaction windows are event-triggered opportunities with eligible actors, urgency, expiry, and allowed action scales; they are not automatic responses or hidden-state access. - Coordinator overrides must record a reason before the artifact can claim training value. - - VoidBot's newer heartbeat routine is the reference for turning this seam into the cast swarm's live timing law: characters take scene turns through heartbeat initiative, cooldown begins after turn completion, and offstage agents use slower rumination/sleep turns for memory consolidation, resonance, incubation, and dreams. See `docs/architecture/voidbot-routine-adoption-plan.md`. + - VoidBot's newer heartbeat routine is the reference for the cast swarm's timing law, and Epiphany now owns the first shared Rust implementation of that law for Ghostlight scene turns. Characters take scene turns through `tools/ghostlight_epiphany_heartbeat.mjs`, cooldown begins after turn completion, and offstage agents will use the same timing law for slower rumination/sleep turns when maintenance heartbeat lands. See `docs/architecture/voidbot-routine-adoption-plan.md`. coordinator: - The coordinator is a trainable soft organ: it stages scenes, chooses next beats and acting agents, invokes specialist machinery, carries unresolved hooks, emits glue prose, and proposes game-engine-shaped state changes. - Future coordinator turns should be sandboxed as their own promptable worker outputs, with exact visible input, raw output, parsed coordinator artifact, review labels, and interventions preserved. Codex acts as meta-coordinator: it scopes, launches, reviews, repairs, and wires Ghostlight organs, but should not silently collapse the trainable coordinator organ back into omniscient chat. @@ -242,5 +242,5 @@ data_flow: - If state grows faster than understanding, cut it back. current_status: - summary: `corvid-collective-founding-v0` is the newest founding-era draft fixture: a sandboxed coordinator plus sandboxed Interactive Fiction Weaver branch-and-fold pass from Kesh-of-Three-Clicks' embodied raven perspective during the 2470 First Exodus. The Ink compiles, lore grounding validates, click-through visual segmentation now exists, and visual-enabled narrative, lore, spatial, and visual reviewers accepted it as draft after applied fixes. It is reference/draft branch and illustrated replay material, not full responder/appraiser/mutator training data. `lucent-hostage-feed-v0` remains the open-ended training-loop draft with validated initiative scheduling; Pallas remains reference-only, with `pallas-training-loop-v0` as the smaller receipt-chain pilot. - next_action: Choose whether to backfill Corvid/NeuroSyn room-scale lore gaps into AetheriaLore, convert the Corvid fixture into a true scene-loop receipt chain, backfill the useful Lucent lore gaps, or move to the next coverage fixture. Keep broad corpus coverage at draft until artifacts have exact receipts for the organs they claim, and keep meta-coordinator repairs labeled. + summary: `corvid-collective-founding-v0` is the newest founding-era draft fixture: a sandboxed coordinator plus sandboxed Interactive Fiction Weaver branch-and-fold pass from Kesh-of-Three-Clicks' embodied raven perspective during the 2470 First Exodus. The Ink compiles, lore grounding validates, click-through visual segmentation now exists, and visual-enabled narrative, lore, spatial, and visual reviewers accepted it as draft after applied fixes. It is reference/draft branch and illustrated replay material, not full responder/appraiser/mutator training data. `lucent-hostage-feed-v0` remains the open-ended training-loop draft with validated initiative scheduling; Pallas remains reference-only, with `pallas-training-loop-v0` as the smaller receipt-chain pilot. Ghostlight now has a local command bridge to Epiphany's Rust heartbeat spine for scene initiative stores and `scene_turn` receipts. + next_action: Use `npm run heartbeat:init` and `npm run heartbeat:tick -- --schedule-out ` when a scene-loop needs live initiative selection, then route the selected character into projector/responder/appraisal/mutation organs. Separately choose whether to backfill Corvid/NeuroSyn room-scale lore gaps into AetheriaLore, convert the Corvid fixture into a true scene-loop receipt chain, backfill the useful Lucent lore gaps, or move to the next coverage fixture. diff --git a/tools/ghostlight_epiphany_heartbeat.mjs b/tools/ghostlight_epiphany_heartbeat.mjs new file mode 100644 index 0000000..19b3948 --- /dev/null +++ b/tools/ghostlight_epiphany_heartbeat.mjs @@ -0,0 +1,313 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const DEFAULT_INITIAL_STATE = + "examples/training-loops/pallas-training-loop-v0/pallas-training-loop-v0.initial-state.json"; +const DEFAULT_STORE = "state/ghostlight-scene-heartbeats.msgpack"; +const DEFAULT_ARTIFACT_DIR = "experiments/heartbeat"; + +function usage() { + console.log(`Usage: + node tools/ghostlight_epiphany_heartbeat.mjs init-scene [options] + node tools/ghostlight_epiphany_heartbeat.mjs tick-scene [options] + node tools/ghostlight_epiphany_heartbeat.mjs status [options] + +Options: + --initial-state Ghostlight scene-loop initial state JSON. + --agent-state Ghostlight agent-state JSON. Defaults from initial state. + --scene-id Scene heartbeat id. Defaults to scene_loop_id. + --scene-ref Source scene ref for receipts. Defaults to initial-state path. + --store MessagePack heartbeat store. Defaults to ${DEFAULT_STORE}. + --artifact-dir Artifact directory for tick/status projections. + --schedule-id Tick schedule id. + --schedule-out Write tick schedule JSON projection to this path. + --epiphany-root EpiphanyAgent root. Defaults to sibling ../EpiphanyAgent. + --no-defer-completion Complete cooldown immediately on tick. +`); +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + const options = {}; + for (let i = 0; i < rest.length; i += 1) { + const token = rest[i]; + if (!token.startsWith("--")) { + throw new Error(`unexpected argument ${token}`); + } + const key = token.slice(2); + if (key === "no-defer-completion") { + options[key] = true; + continue; + } + const value = rest[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`missing value for ${token}`); + } + options[key] = value; + i += 1; + } + return { command, options }; +} + +function resolveRepoPath(input, fallback) { + const value = input || fallback; + const resolved = path.isAbsolute(value) ? value : path.resolve(ROOT, value); + return resolved; +} + +function toRepoRef(filePath) { + const relative = path.relative(ROOT, filePath).replaceAll("\\", "/"); + return relative.startsWith("..") ? filePath : relative; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); +} + +function cleanRef(ref) { + return ref.split("#", 1)[0]; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function dimensionMean(groups, names, fallback) { + for (const groupName of Object.keys(groups || {})) { + const group = groups[groupName] || {}; + for (const name of names) { + const entry = group[name]; + const value = entry?.current_activation ?? entry?.mean; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + } + return fallback; +} + +function seedForAgent(agent, initialState) { + const canonical = agent.canonical_state || {}; + const initiativeSource = dimensionMean( + { + behavioral_dimensions: canonical.behavioral_dimensions, + stable_dispositions: canonical.stable_dispositions, + }, + ["drive", "risk_triage", "cautious_courage", "coalition_building"], + 0.5, + ); + const reactionSource = dimensionMean( + { + situational_state: canonical.situational_state, + stable_dispositions: canonical.stable_dispositions, + }, + ["protective_alarm", "anxiety", "baseline_threat_sensitivity", "suspicion"], + 0.5, + ); + const thresholdSource = dimensionMean( + { + stable_dispositions: canonical.stable_dispositions, + behavioral_dimensions: canonical.behavioral_dimensions, + }, + ["risk_tolerance", "control_pressure", "rigidity"], + 0.5, + ); + const speed = clamp(0.7 + initiativeSource * 0.75, 0.35, 1.65); + const reaction = clamp(0.2 + reactionSource * 0.7, 0.05, 0.95); + const threshold = clamp(0.75 - thresholdSource * 0.45, 0.15, 0.85); + const displayName = agent.identity?.name || agent.agent_id; + const constraints = [ + `Scene loop: ${initialState.scene_loop_id || initialState.state_id || "unknown"}.`, + "Receives only projected local context; no hidden author omniscience.", + ]; + return [ + agent.agent_id, + String(displayName).replaceAll("|", "/"), + speed.toFixed(3), + reaction.toFixed(3), + threshold.toFixed(3), + constraints.join("; "), + ].join("|"); +} + +function sceneSeeds(initialStatePath, agentStatePath) { + const initialState = readJson(initialStatePath); + const agentStateRef = agentStatePath + ? agentStatePath + : resolveRepoPath(cleanRef(initialState.reference_agent_state_ref), undefined); + const agentState = readJson(agentStateRef); + const participantIds = Object.keys(initialState.participant_state_refs || {}); + if (!participantIds.length) { + throw new Error(`no participant_state_refs found in ${initialStatePath}`); + } + const byId = new Map((agentState.agents || []).map((agent) => [agent.agent_id, agent])); + const seeds = participantIds.map((id) => { + const agent = byId.get(id); + if (!agent) { + throw new Error(`participant ${id} is missing from ${agentStateRef}`); + } + return seedForAgent(agent, initialState); + }); + return { initialState, seeds }; +} + +function epiphanyCommand(options, args) { + const epiphanyRoot = path.resolve( + options["epiphany-root"] || path.join(ROOT, "..", "EpiphanyAgent"), + ); + const manifest = path.join(epiphanyRoot, "epiphany-core", "Cargo.toml"); + if (!existsSync(manifest)) { + throw new Error(`Epiphany heartbeat manifest not found at ${manifest}`); + } + return ["run", "--manifest-path", manifest, "--bin", "epiphany-heartbeat-store", "--", ...args]; +} + +function runEpiphany(options, args) { + const cargoArgs = epiphanyCommand(options, args); + const result = spawnSync("cargo", cargoArgs, { + cwd: ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + CARGO_TARGET_DIR: + process.env.CARGO_TARGET_DIR || "C:\\Users\\Meta\\.cargo-target-codex", + }, + }); + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (result.status !== 0) { + throw new Error(`Epiphany heartbeat command failed with exit ${result.status}`); + } + const jsonLine = result.stdout + .trim() + .split(/\r?\n/) + .reverse() + .find((line) => line.trim().startsWith("{")); + return jsonLine ? JSON.parse(jsonLine) : null; +} + +function writeAuditNote() { + const auditPath = path.join(ROOT, "notes", "epiphany-intrusion-audit.md"); + if (existsSync(auditPath)) { + return; + } + writeFileSync( + auditPath, + `# Epiphany Intrusion Audit + +EpiphanyAgent touched this workspace to add the first Ghostlight-facing bridge +to Epiphany's Rust heartbeat initiative store. + +Scope: + +- no scene fixtures were rewritten +- no accepted narrative artifacts were mutated +- Ghostlight receives a command wrapper that invokes Epiphany's shared Rust + heartbeat scheduler for scene participants +- initiative schema/validation now accepts scheduler-level \`scene_turn\` + receipts and arena metadata emitted by that shared spine + +Resident agents should treat this as an audited external intervention, not as +their own invisible local work. +`, + "utf8", + ); +} + +function initScene(options) { + const initialStatePath = resolveRepoPath(options["initial-state"], DEFAULT_INITIAL_STATE); + const agentStatePath = options["agent-state"] + ? resolveRepoPath(options["agent-state"], undefined) + : undefined; + const { initialState, seeds } = sceneSeeds(initialStatePath, agentStatePath); + const sceneId = options["scene-id"] || initialState.scene_loop_id || initialState.state_id; + if (!sceneId) { + throw new Error("scene id is required when initial state lacks scene_loop_id/state_id"); + } + const store = resolveRepoPath(options.store, DEFAULT_STORE); + mkdirSync(path.dirname(store), { recursive: true }); + const args = [ + "init", + "--store", + store, + "--profile", + "ghostlight-scene", + "--scene-id", + sceneId, + ]; + for (const seed of seeds) { + args.push("--scene-participant", seed); + } + return runEpiphany(options, args); +} + +function tickScene(options) { + const initialStatePath = resolveRepoPath(options["initial-state"], DEFAULT_INITIAL_STATE); + const initialState = readJson(initialStatePath); + const store = resolveRepoPath(options.store, DEFAULT_STORE); + const artifactDir = resolveRepoPath(options["artifact-dir"], DEFAULT_ARTIFACT_DIR); + mkdirSync(artifactDir, { recursive: true }); + const scheduleId = + options["schedule-id"] || + `${options["scene-id"] || initialState.scene_loop_id || "ghostlight-scene"}.heartbeat-${Date.now()}`; + const sceneRef = options["scene-ref"] || toRepoRef(initialStatePath); + const args = [ + "tick", + "--store", + store, + "--artifact-dir", + artifactDir, + "--schedule-id", + scheduleId, + "--source-scene-ref", + sceneRef, + ]; + if (!options["no-defer-completion"]) { + args.push("--defer-completion"); + } + const output = runEpiphany(options, args); + if (options["schedule-out"] && output?.schedule) { + const scheduleOut = resolveRepoPath(options["schedule-out"], undefined); + mkdirSync(path.dirname(scheduleOut), { recursive: true }); + writeFileSync(scheduleOut, `${JSON.stringify(output.schedule, null, 2)}\n`, "utf8"); + console.log(JSON.stringify({ ok: true, scheduleOut: toRepoRef(scheduleOut) })); + } + return output; +} + +function status(options) { + const store = resolveRepoPath(options.store, DEFAULT_STORE); + const artifactDir = resolveRepoPath(options["artifact-dir"], DEFAULT_ARTIFACT_DIR); + mkdirSync(artifactDir, { recursive: true }); + return runEpiphany(options, ["status", "--store", store, "--artifact-dir", artifactDir]); +} + +try { + const { command, options } = parseArgs(process.argv.slice(2)); + if (!command || command === "help" || command === "--help") { + usage(); + process.exit(0); + } + writeAuditNote(); + if (command === "init-scene") { + initScene(options); + } else if (command === "tick-scene") { + tickScene(options); + } else if (command === "status") { + status(options); + } else { + throw new Error(`unknown command ${command}`); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/tools/validate_initiative_schedules.py b/tools/validate_initiative_schedules.py index d13b6a8..61b4684 100644 --- a/tools/validate_initiative_schedules.py +++ b/tools/validate_initiative_schedules.py @@ -12,6 +12,7 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_PATHS = sorted((ROOT / "examples" / "initiative").glob("*.json")) ACTION_TYPES = { + "scene_turn", "speak", "silence", "move", @@ -103,6 +104,14 @@ def validate_participant(value: Any, path: str) -> dict[str, Any]: require_number(value["current_load"], f"{path}.current_load", minimum=0, maximum=1) require(value["status"] in PARTICIPANT_STATUSES, f"{path}.status is invalid") require_string_array(value["constraints"], f"{path}.constraints") + if "arena" in value: + require(value["arena"] in {"scene", "maintenance"}, f"{path}.arena is invalid") + if "participant_kind" in value: + require(value["participant_kind"] in {"character", "agent"}, f"{path}.participant_kind is invalid") + if "role_id" in value: + require_string(value["role_id"], f"{path}.role_id") + if "pending_turn" in value: + require(value["pending_turn"] is None or isinstance(value["pending_turn"], dict), f"{path}.pending_turn must be object or null") return value @@ -133,6 +142,10 @@ def validate_action(value: Any, path: str, participant_ids: set[str]) -> dict[st require_number(value["interruptibility"], f"{path}.interruptibility", minimum=0, maximum=1) require_number(value["commitment"], f"{path}.commitment", minimum=0, maximum=1) require_string_array(value["local_affordance_basis"], f"{path}.local_affordance_basis", nonempty=True) + if "arena" in value: + require(value["arena"] in {"scene", "maintenance"}, f"{path}.arena is invalid") + if "participant_kind" in value: + require(value["participant_kind"] in {"character", "agent"}, f"{path}.participant_kind is invalid") return value @@ -261,6 +274,10 @@ def validate_schedule(document: dict[str, Any], source: Path) -> None: if item["reaction_readiness"] is not None: require_number(item["reaction_readiness"], f"{item_path}.reaction_readiness") require(isinstance(item["eligible_for_reaction"], bool), f"{item_path}.eligible_for_reaction must be boolean") + if "arena" in item: + require(item["arena"] in {"scene", "maintenance"}, f"{item_path}.arena is invalid") + if "participant_kind" in item: + require(item["participant_kind"] in {"character", "agent"}, f"{item_path}.participant_kind is invalid") require(snapshot_ids == set(participant_ids), f"{base}.next_actor_selection.readiness_snapshot must include every participant") if selection["selection_kind"] == "coordinator_override": From a1ec7ace5d8555a0eb04c227e3d83ca2f1da9a23 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 29 May 2026 16:09:25 +0100 Subject: [PATCH 2/5] Adopt shared PersonaState contract --- .../cultnet-shared-agent-state-contract.md | 134 +-- .../persona-state.call-of-the-void.oz.json | 89 ++ notes/fresh-workspace-handoff.md | 2 +- package.json | 3 +- schemas/gamecult.persona_state.v0.schema.json | 1024 +++++++++++++++++ state/evidence.jsonl | 3 +- state/ghostlight-state.cultcache.jsonl | 1 + state/map.yaml | 3 +- tools/validate_persona_state.py | 197 ++++ 9 files changed, 1387 insertions(+), 69 deletions(-) create mode 100644 examples/persona-state.call-of-the-void.oz.json create mode 100644 schemas/gamecult.persona_state.v0.schema.json create mode 100644 tools/validate_persona_state.py diff --git a/docs/architecture/cultnet-shared-agent-state-contract.md b/docs/architecture/cultnet-shared-agent-state-contract.md index bb59a7b..966e672 100644 --- a/docs/architecture/cultnet-shared-agent-state-contract.md +++ b/docs/architecture/cultnet-shared-agent-state-contract.md @@ -1,98 +1,102 @@ -# CultNet Shared Agent-State Contract +# CultNet Shared Persona-State Contract -Ghostlight owns the first cross-runtime agent-state payload contract. +Ghostlight no longer treats `ghostlight.agent_state.v0` as the cross-runtime +person-state contract. That schema still belongs here, but it owns scene, +world, relationship, event, and dialogue-context state for storytelling. -That contract is not a new ontology. It is the existing Ghostlight state shape -moving over a wire without turning into bespoke JSON fog in every other repo. +The portable person-shaped contract is now `gamecult.persona_state.v0`. ## Canonical Payload -The first shared payload contract is: +The shared payload contract is: - schema id: - `https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json` + `https://gamecult.dev/cultnet/gamecult.persona_state.v0.schema.json` - schema version: - `ghostlight.agent_state.v0` -- canonical file: - `schemas/agent-state.schema.json` + `gamecult.persona_state.v0` +- Ghostlight mirror: + `schemas/gamecult.persona_state.v0.schema.json` -This is the same state shape already used for Ghostlight fixtures and the same -top-level shape Epiphany role dossiers adopted. The contract is: +The same schema is mirrored by Epiphany and VoidBot so Faces, repo +representatives, and Ghostlight characters can cross runtime boundaries without +each repo inventing its own local person-shaped blob. -- `world` -- `agents` -- `relationships` -- `events` -- `scenes` +## Ownership -If another runtime wants to carry socially persistent agent state without -inventing its own private folk religion, this is the first house shape. +Ghostlight keeps owning the story machine: + +- world state +- scenes +- events +- relationships +- perceived overlays +- dialogue context packs + +PersonaState owns the portable public-person projection: + +- presentation +- values +- activation profile +- thought memory +- agency pressure +- candidate actions +- affect +- source provenance + +That projection can be generated from Ghostlight scene agents, VoidBot repo Face +state, or Epiphany Face state. The projection does not erase native state. It is +the wire shape, not a throne. ## Wire Envelope -CultNet should carry the Ghostlight payload as a typed document replication -message, not as a one-off ad hoc blob. +CultNet should carry PersonaState as a typed document replication message. Current expected document envelope: +- `documentType`: `gamecult.persona-state` +- `documentKey`: stable runtime-defined persona key +- `payloadSchemaVersion`: `gamecult.persona_state.v0` +- `payload`: exact PersonaState document + +Native Ghostlight scene payloads may still travel separately with: + - `documentType`: `ghostlight.agent-state` -- `documentKey`: runtime-defined stable key - `payloadSchemaVersion`: `ghostlight.agent_state.v0` -- `payload`: exact Ghostlight state document -The current TypeScript client library is: +Those are different contracts. One carries a story world. One carries a +portable Persona. -- repo: `GameCult/cultnet-ts` +## Epiphany Boundary -Its job is not merely to make pipes pretty. It owns: +Epiphany public Faces use PersonaState when they need full persistent +personhood. Epiphany work organs do not. A work organ such as Imagination can +have a light organ state without affect, social bonds, public presentation, or +candidate actions. -- direct-pipe MessagePack framing -- typed message contracts -- authentication/session semantics compatible with CultLib's `GameCult.Networking` -- typed replication into CultCache-compatible stores - -## Authentication Story - -CultNet inherits the useful security shape from CultLib: - -- a shared connection key known to client and server/runtime peers -- AES-GCM encrypted auth/session payloads derived from that key -- a server-side session-signing secret -- signed session tokens used for verify/reconnect flows - -This matters because "shared agent state" is not only a serialization problem. -It is also a trust boundary problem. We want one family resemblance across C#, -TypeScript, Rust, and Python instead of four mutually unintelligible little -shrines to local convenience. +This prevents the old muddle where every useful subsystem was tempted to dress +up as a whole person. Useful machines do not all need faces. ## Contract Discipline -Ghostlight remains the source of truth for the payload shape. +The schema lives in multiple repos for local validation, but the contract must +move as one: -That means: +- Epiphany keeps the primary CultNet schema copy. +- VoidBot mirrors it and emits PersonaState from repo Face reads. +- Ghostlight mirrors it and validates PersonaState examples. +- Native storage may stay richer than PersonaState. +- Importers must preserve unknown extension data when possible. +- Consumers must treat `provenance.authority` seriously. -- if `schemas/agent-state.schema.json` changes, CultNet mirrors must update in - lockstep -- consumers may add local document metadata around the payload, but should not - silently mutate the payload contract itself -- if a runtime needs a narrower projection or local working view, that is a - derivative contract, not a revision of `ghostlight.agent_state.v0` - -The rule is simple: - -- prompt projection may differ -- storage engines may differ -- language bindings may differ -- the shared payload contract does not drift just because another repo got - lonely +Prompt projection may differ. Storage engines may differ. Story scenes may +carry more structure. The shared person-state payload does not drift just +because a runtime got clever at midnight. ## Immediate Consumers The first intended consumers are: -- Ghostlight fixture/state tooling -- EpiphanyAgent role dossiers and heartbeat-adjacent agent state -- VoidBot companion-state and social memory machinery where the full Ghostlight - payload is useful - -More bindings can come later. They do not get to invent a prettier truth. +- Ghostlight character projections for scene/story tooling +- VoidBot repo Face MCP reads and social memory interop +- Epiphany public Face state +- future CultNet replication and inspection tools diff --git a/examples/persona-state.call-of-the-void.oz.json b/examples/persona-state.call-of-the-void.oz.json new file mode 100644 index 0000000..ad211ca --- /dev/null +++ b/examples/persona-state.call-of-the-void.oz.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": "gamecult.persona_state.v0", + "provenance": { + "sourceSystem": "ghostlight", + "sourceDocumentId": "examples/agent-state.call-of-the-void.json#agents/oz", + "sourceUpdatedAt": "2026-05-29T00:00:00Z", + "exportedAt": "2026-05-29T00:00:00Z", + "authority": "projection" + }, + "personaId": "oz", + "publicName": "Oz", + "publicDescription": "A hostage negotiator carrying pressure, empathy, and tactical restraint into an impossible room.", + "presentation": { + "voiceSummary": "Oz speaks with controlled warmth, plain stakes, and a careful refusal to let urgency become panic.", + "defaultRenderer": "scene", + "homeContext": { + "kind": "scene", + "id": "call-of-the-void", + "label": "Call of the Void" + }, + "jurisdiction": "Lucent hostage negotiation scene" + }, + "privateNotes": [ + "Ghostlight owns scene-local truth; PersonaState is the portable character projection." + ], + "values": [ + { + "id": "preserve-life", + "label": "Preserve life without surrendering agency", + "priority": 0.95, + "summary": "Keep everyone alive while preserving the person's ability to choose a way back." + } + ], + "activationProfile": { + "underlyingOrganization": {}, + "stableDispositions": {}, + "behavioralDimensions": {}, + "presentationStrategy": {}, + "voiceStyle": {}, + "situationalState": {} + }, + "thoughtMemory": { + "shortTerm": [], + "memories": [ + { + "id": "oz-memory-room-pressure", + "status": "active", + "target": { + "kind": "scene", + "id": "call-of-the-void", + "label": "Call of the Void" + }, + "summary": "The room is not a puzzle box. It is a living pressure system with frightened people inside it.", + "claim": "A tactical win that humiliates the subject can still lose the person.", + "tension": "Delay has risk, but speed can become coercion wearing a heroic mask.", + "actionImplication": "Speak in ways that lower pressure while keeping real choices visible.", + "intensity": 0.86, + "createdAt": "2026-05-29T00:00:00Z", + "updatedAt": "2026-05-29T00:00:00Z", + "tags": [ + "ghostlight", + "call-of-the-void", + "negotiation" + ], + "extensions": { + "ghostlight": { + "sourceSchemaVersion": "ghostlight.agent_state.v0" + } + } + } + ], + "incubation": [] + }, + "agencyPressure": { + "pressures": [] + }, + "candidateActions": { + "actions": [] + }, + "affect": { + "needs": [], + "socialBonds": [], + "statusReads": [], + "moodDimensions": [], + "socialBiases": [], + "doctrineStances": [] + }, + "updatedAt": "2026-05-29T00:00:00Z" +} diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 3f3869f..fe88f17 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -344,7 +344,7 @@ readiness or reference-only value, not accepted broad corpus fixtures. ## Warnings - Keep persistent state small. Git history and artifacts own chronology. -- Treat `ghostlight.agent_state.v0` as the first shared cross-runtime payload contract. If another runtime needs to move agent state over a wire, it should carry the Ghostlight payload unchanged inside a typed CultNet document envelope instead of quietly forking the ontology. +- Treat `gamecult.persona_state.v0` as the shared cross-runtime public-person payload contract. `ghostlight.agent_state.v0` remains Ghostlight's scene/world contract, not the portable Persona schema. - Do not let notes, map, handoff, and evidence become four versions of the same brain. - Do not train on coordinator-repaired responder prose unless the repair is labeled. - Do not accept branch fixtures without branch compiler notes and IF artifact review. diff --git a/package.json b/package.json index 96e90eb..3d16027 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "state:status": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py status", "state:prepare-compaction": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_prepare_compaction.py", "agent-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_agent_state.py", - "schema:validate": "npm run agent-state:validate && npm run projection:validate && npm run projected-context:validate && npm run lore-grounding:validate && npm run coordinator:validate && npm run responder-packets:validate && npm run responder-outputs:validate && npm run training-loop:validate && npm run initiative:validate && npm run ink:validate && npm run coverage:status", + "persona-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_persona_state.py", + "schema:validate": "npm run agent-state:validate && npm run persona-state:validate && npm run projection:validate && npm run projected-context:validate && npm run lore-grounding:validate && npm run coordinator:validate && npm run responder-packets:validate && npm run responder-outputs:validate && npm run training-loop:validate && npm run initiative:validate && npm run ink:validate && npm run coverage:status", "projection:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_projection_examples.py", "projected-context:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_projected_contexts.py", "lore-grounding:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_lore_grounding.py", diff --git a/schemas/gamecult.persona_state.v0.schema.json b/schemas/gamecult.persona_state.v0.schema.json new file mode 100644 index 0000000..2537628 --- /dev/null +++ b/schemas/gamecult.persona_state.v0.schema.json @@ -0,0 +1,1024 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://gamecult.dev/cultnet/gamecult.persona_state.v0.schema.json", + "title": "GameCult Persona State v0", + "description": "Portable person-state contract for Epiphany Face, VoidBot repo Faces, and Ghostlight characters. Epiphany work organs should not use this unless they are acting as a public Persona.", + "type": "object", + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "gamecult.persona_state.v0" + }, + "provenance": { + "$ref": "#/definitions/provenance" + }, + "personaId": { + "type": "string" + }, + "publicName": { + "type": "string" + }, + "publicDescription": { + "type": "string" + }, + "presentation": { + "$ref": "#/definitions/presentation" + }, + "privateNotes": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "values": { + "type": "array", + "items": { + "$ref": "#/definitions/value" + }, + "default": [] + }, + "activationProfile": { + "$ref": "#/definitions/activationProfile" + }, + "thoughtMemory": { + "$ref": "#/definitions/thoughtMemory" + }, + "agencyPressure": { + "$ref": "#/definitions/agencyPressure" + }, + "candidateActions": { + "$ref": "#/definitions/candidateActions" + }, + "voidbotProjection": { + "$ref": "#/definitions/voidbotProjection" + }, + "affect": { + "$ref": "#/definitions/personaAffect" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "schemaVersion", + "provenance", + "personaId", + "publicName", + "presentation", + "activationProfile", + "thoughtMemory", + "agencyPressure", + "candidateActions", + "affect", + "updatedAt" + ], + "definitions": { + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "sourceSystem": { + "type": "string" + }, + "sourceDocumentId": { + "type": "string" + }, + "sourceUpdatedAt": { + "type": "string", + "format": "date-time" + }, + "exportedAt": { + "type": "string", + "format": "date-time" + }, + "authority": { + "type": "string", + "enum": [ + "canonical", + "projection", + "import" + ] + } + }, + "required": [ + "sourceSystem", + "sourceDocumentId", + "sourceUpdatedAt", + "exportedAt", + "authority" + ] + }, + "publicHandle": { + "type": "object", + "additionalProperties": false, + "properties": { + "system": { + "type": "string" + }, + "handle": { + "type": "string" + }, + "uri": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "system", + "handle" + ] + }, + "presentation": { + "type": "object", + "additionalProperties": false, + "properties": { + "avatarUri": { + "type": "string", + "format": "uri" + }, + "pronouns": { + "type": "string" + }, + "voiceSummary": { + "type": "string" + }, + "defaultRenderer": { + "type": "string", + "enum": [ + "text", + "chat", + "avatar", + "voice", + "scene", + "repo_face", + "custom" + ] + }, + "customRenderer": { + "type": "string" + }, + "homeContext": { + "$ref": "#/definitions/target" + }, + "jurisdiction": { + "type": "string" + }, + "publicHandles": { + "type": "array", + "items": { + "$ref": "#/definitions/publicHandle" + }, + "default": [] + } + }, + "required": [ + "voiceSummary" + ], + "allOf": [ + { + "if": { + "properties": { + "defaultRenderer": { + "const": "custom" + } + }, + "required": [ + "defaultRenderer" + ] + }, + "then": { + "required": [ + "customRenderer" + ] + } + } + ] + }, + "traitVector": { + "type": "object", + "additionalProperties": false, + "properties": { + "mean": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "plasticity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "currentActivation": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "mean", + "plasticity", + "currentActivation" + ] + }, + "traitMap": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/traitVector" + } + }, + "activationProfile": { + "type": "object", + "additionalProperties": false, + "properties": { + "underlyingOrganization": { + "$ref": "#/definitions/traitMap" + }, + "stableDispositions": { + "$ref": "#/definitions/traitMap" + }, + "behavioralDimensions": { + "$ref": "#/definitions/traitMap" + }, + "presentationStrategy": { + "$ref": "#/definitions/traitMap" + }, + "voiceStyle": { + "$ref": "#/definitions/traitMap" + }, + "situationalState": { + "$ref": "#/definitions/traitMap" + } + }, + "required": [ + "underlyingOrganization", + "stableDispositions", + "behavioralDimensions", + "presentationStrategy", + "voiceStyle", + "situationalState" + ] + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "priority": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "summary": { + "type": "string" + } + }, + "required": [ + "id", + "label", + "priority" + ] + }, + "target": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "person", + "repo", + "scene", + "system", + "room", + "artifact", + "concept", + "relationship", + "self", + "community", + "thread", + "document", + "runtime", + "custom" + ] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "customKind": { + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "custom" + } + }, + "required": [ + "kind" + ] + }, + "then": { + "required": [ + "customKind" + ] + } + } + ] + }, + "thoughtStatus": { + "type": "string", + "enum": [ + "draft", + "active", + "cooling", + "crystallized", + "resolved", + "retired" + ] + }, + "extensions": { + "type": "object", + "description": "Non-authoritative extension data from source systems. Portable consumers may preserve it, but must not treat it as core PersonaState authority unless they understand the source contract.", + "additionalProperties": true + }, + "anchoredThought": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/thoughtStatus" + }, + "target": { + "$ref": "#/definitions/target" + }, + "summary": { + "type": "string" + }, + "claim": { + "type": "string" + }, + "question": { + "type": "string" + }, + "tension": { + "type": "string" + }, + "actionImplication": { + "type": "string" + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "valence": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "retiredAt": { + "type": "string", + "format": "date-time" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "extensions": { + "$ref": "#/definitions/extensions" + } + }, + "required": [ + "id", + "status", + "target", + "summary", + "tension", + "actionImplication", + "createdAt", + "updatedAt" + ] + }, + "thoughtMemory": { + "type": "object", + "additionalProperties": false, + "properties": { + "shortTerm": { + "type": "array", + "items": { + "$ref": "#/definitions/anchoredThought" + }, + "default": [] + }, + "memories": { + "type": "array", + "items": { + "$ref": "#/definitions/anchoredThought" + }, + "default": [] + }, + "incubation": { + "type": "array", + "items": { + "$ref": "#/definitions/anchoredThought" + }, + "default": [] + } + }, + "required": [ + "shortTerm", + "memories", + "incubation" + ] + }, + "agencyPressure": { + "type": "object", + "additionalProperties": false, + "properties": { + "pressures": { + "type": "array", + "items": { + "$ref": "#/definitions/anchoredThought" + }, + "default": [] + } + }, + "required": [ + "pressures" + ] + }, + "candidateActions": { + "type": "object", + "additionalProperties": false, + "properties": { + "actions": { + "type": "array", + "items": { + "$ref": "#/definitions/candidateAction" + }, + "default": [] + } + }, + "required": [ + "actions" + ] + }, + "candidateAction": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/thoughtStatus" + }, + "actionType": { + "type": "string", + "enum": [ + "speak", + "draft", + "ask", + "propose", + "inspect", + "notify", + "wait", + "remember", + "handoff", + "render", + "external_action", + "custom" + ] + }, + "customActionType": { + "type": "string" + }, + "readiness": { + "type": "string", + "enum": [ + "draft", + "ready", + "blocked", + "waiting", + "expired" + ] + }, + "riskLevel": { + "type": "string", + "enum": [ + "none", + "low", + "medium", + "high", + "severe", + "unknown" + ] + }, + "target": { + "$ref": "#/definitions/target" + }, + "deliveryTarget": { + "$ref": "#/definitions/target" + }, + "summary": { + "type": "string" + }, + "rationale": { + "type": "string" + }, + "urgency": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "constraints": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "evidence": { + "type": "array", + "items": { + "$ref": "#/definitions/anchoredThought" + }, + "default": [] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "extensions": { + "$ref": "#/definitions/extensions" + } + }, + "required": [ + "id", + "status", + "actionType", + "readiness", + "riskLevel", + "target", + "summary", + "createdAt", + "updatedAt" + ], + "allOf": [ + { + "if": { + "properties": { + "actionType": { + "const": "custom" + } + }, + "required": [ + "actionType" + ] + }, + "then": { + "required": [ + "customActionType" + ] + } + } + ] + }, + "voidbotProjection": { + "type": "object", + "additionalProperties": false, + "properties": { + "candidateInterventions": { + "type": "array", + "description": "VoidBot-flavored projection of candidateActions for repo Face routines. This is not the generic PersonaState action authority.", + "items": { + "$ref": "#/definitions/candidateAction" + }, + "default": [] + } + }, + "required": [ + "candidateInterventions" + ] + }, + "moodDimension": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "source": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "name", + "value", + "updatedAt" + ] + }, + "socialBias": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "summary": { + "type": "string" + }, + "behavioralPull": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "name", + "value", + "summary", + "behavioralPull", + "updatedAt" + ] + }, + "socialBond": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/thoughtStatus" + }, + "subject": { + "$ref": "#/definitions/target" + }, + "object": { + "$ref": "#/definitions/target" + }, + "relationshipKind": { + "type": "string", + "enum": [ + "ally", + "friend", + "collaborator", + "mentor", + "ward", + "rival", + "audience", + "community_member", + "self_relation", + "unknown", + "custom" + ] + }, + "customRelationshipKind": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "trust": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "tension": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "lastEvidence": { + "$ref": "#/definitions/anchoredThought" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "extensions": { + "$ref": "#/definitions/extensions" + } + }, + "required": [ + "id", + "status", + "subject", + "object", + "relationshipKind", + "summary", + "trust", + "tension", + "updatedAt" + ], + "allOf": [ + { + "if": { + "properties": { + "relationshipKind": { + "const": "custom" + } + }, + "required": [ + "relationshipKind" + ] + }, + "then": { + "required": [ + "customRelationshipKind" + ] + } + } + ] + }, + "statusRead": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/thoughtStatus" + }, + "target": { + "$ref": "#/definitions/target" + }, + "statusKind": { + "type": "string", + "enum": [ + "attention", + "authority", + "risk", + "trust", + "need", + "conflict", + "mood", + "opportunity", + "uncertainty", + "custom" + ] + }, + "customStatusKind": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "valence": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "lastEvidence": { + "$ref": "#/definitions/anchoredThought" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "extensions": { + "$ref": "#/definitions/extensions" + } + }, + "required": [ + "id", + "status", + "target", + "statusKind", + "summary", + "confidence", + "updatedAt" + ], + "allOf": [ + { + "if": { + "properties": { + "statusKind": { + "const": "custom" + } + }, + "required": [ + "statusKind" + ] + }, + "then": { + "required": [ + "customStatusKind" + ] + } + } + ] + }, + "doctrineStance": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/thoughtStatus" + }, + "target": { + "$ref": "#/definitions/target" + }, + "stanceKind": { + "type": "string", + "enum": [ + "aligned", + "tension", + "rejected", + "uncertain", + "contextual", + "custom" + ] + }, + "customStanceKind": { + "type": "string" + }, + "principle": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "actionImplication": { + "type": "string" + }, + "intensity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "extensions": { + "$ref": "#/definitions/extensions" + } + }, + "required": [ + "id", + "status", + "target", + "stanceKind", + "principle", + "summary", + "actionImplication", + "updatedAt" + ], + "allOf": [ + { + "if": { + "properties": { + "stanceKind": { + "const": "custom" + } + }, + "required": [ + "stanceKind" + ] + }, + "then": { + "required": [ + "customStanceKind" + ] + } + } + ] + }, + "personaAffect": { + "type": "object", + "additionalProperties": false, + "properties": { + "needs": { + "type": "array", + "items": { + "$ref": "#/definitions/anchoredThought" + }, + "default": [] + }, + "socialBonds": { + "type": "array", + "items": { + "$ref": "#/definitions/socialBond" + }, + "default": [] + }, + "statusReads": { + "type": "array", + "items": { + "$ref": "#/definitions/statusRead" + }, + "default": [] + }, + "moodDimensions": { + "type": "array", + "items": { + "$ref": "#/definitions/moodDimension" + }, + "default": [] + }, + "socialBiases": { + "type": "array", + "items": { + "$ref": "#/definitions/socialBias" + }, + "default": [] + }, + "doctrineStances": { + "type": "array", + "items": { + "$ref": "#/definitions/doctrineStance" + }, + "default": [] + } + }, + "required": [ + "needs", + "socialBonds", + "statusReads", + "moodDimensions", + "socialBiases", + "doctrineStances" + ] + } + } +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 99c2208..75695d6 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -65,5 +65,6 @@ {"note": "VoidBot's new routine should become Ghostlight's cast swarm timing and memory substrate: characters take scene turns through heartbeat initiative, offstage agents use slower rumination/sleep turns for consolidation, canonical state moves toward CultCache-style MessagePack with JSON projections, and memory resonance/incubation/dream state should be backed by Qdrant vectors. Captured the plan in docs/architecture/voidbot-routine-adoption-plan.md.", "status": "ok", "ts": "2026-05-06T00:25:00+00:00", "type": "architecture"} {"note": "Ghostlight began migrating machine-managed persistent state behind cultcache-py: branch and evidence records now route through a registered CultCache seam with readable JSON exports kept for diffability and compatibility.", "status": "accepted", "ts": "2026-05-06T03:46:41+00:00", "type": "architecture"} {"note": "CultCache wire contract clarified: DatabaseEntry payloads must be MessagePack arrays indexed by explicit field keys with nil gaps for reserved/deleted slots; cultcache-py now exposes define_database_entry_type for that contract and CultLib generator was patched to preserve sparse key slots.", "status": "accepted", "ts": "2026-05-06T03:57:10+00:00", "type": "architecture"} -{"ts": "2026-05-06T04:49:20+00:00", "type": "architecture", "status": "accepted", "note": "Ghostlight agent-state schema is now the first shared cross-runtime payload contract. CultNet clients should carry ghostlight.agent_state.v0 unchanged inside typed document envelopes, with CultLib-style auth/session semantics handled by the transport layer rather than bespoke per-repo glue."} +{"note": "Ghostlight agent-state schema is now the first shared cross-runtime payload contract. CultNet clients should carry ghostlight.agent_state.v0 unchanged inside typed document envelopes, with CultLib-style auth/session semantics handled by the transport layer rather than bespoke per-repo glue.", "status": "accepted", "ts": "2026-05-06T04:49:20+00:00", "type": "architecture"} {"ts": "2026-05-06T13:32:30+00:00", "type": "implementation", "status": "ok", "note": "Ghostlight now has a local Epiphany heartbeat bridge: tools/ghostlight_epiphany_heartbeat.mjs derives scene participants from existing initial/agent-state fixtures, invokes Epiphany's Rust epiphany-heartbeat-store ghostlight-scene profile, writes typed MessagePack scene heartbeat state, and emits validator-accepted scene_turn initiative receipts. This replaces the need for a parallel Ghostlight scheduler for the first scene-heartbeat slice."} +{"ts": "2026-05-29T15:07:33+00:00", "type": "architecture", "status": "accepted", "note": "Ghostlight no longer treats ghostlight.agent_state.v0 as the shared person-state wire contract. It mirrors gamecult.persona_state.v0 for portable Persona projections while keeping ghostlight.agent_state.v0 as the scene/world/story state contract."} diff --git a/state/ghostlight-state.cultcache.jsonl b/state/ghostlight-state.cultcache.jsonl index 1644bf6..9689fc6 100644 --- a/state/ghostlight-state.cultcache.jsonl +++ b/state/ghostlight-state.cultcache.jsonl @@ -67,3 +67,4 @@ {"key": "2026-05-06T034641Z0000-0065-ea57d8ad2b40", "payload": "eyJub3RlIjogIkdob3N0bGlnaHQgYmVnYW4gbWlncmF0aW5nIG1hY2hpbmUtbWFuYWdlZCBwZXJzaXN0ZW50IHN0YXRlIGJlaGluZCBjdWx0Y2FjaGUtcHk6IGJyYW5jaCBhbmQgZXZpZGVuY2UgcmVjb3JkcyBub3cgcm91dGUgdGhyb3VnaCBhIHJlZ2lzdGVyZWQgQ3VsdENhY2hlIHNlYW0gd2l0aCByZWFkYWJsZSBKU09OIGV4cG9ydHMga2VwdCBmb3IgZGlmZmFiaWxpdHkgYW5kIGNvbXBhdGliaWxpdHkuIiwgInN0YXR1cyI6ICJhY2NlcHRlZCIsICJ0cyI6ICIyMDI2LTA1LTA2VDAzOjQ2OjQxKzAwOjAwIiwgInR5cGUiOiAiYXJjaGl0ZWN0dXJlIn0=", "stored_at": "2026-05-06T03:46:41.487861+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-05-06T035710Z0000-0066-272010506962", "payload": "eyJub3RlIjogIkN1bHRDYWNoZSB3aXJlIGNvbnRyYWN0IGNsYXJpZmllZDogRGF0YWJhc2VFbnRyeSBwYXlsb2FkcyBtdXN0IGJlIE1lc3NhZ2VQYWNrIGFycmF5cyBpbmRleGVkIGJ5IGV4cGxpY2l0IGZpZWxkIGtleXMgd2l0aCBuaWwgZ2FwcyBmb3IgcmVzZXJ2ZWQvZGVsZXRlZCBzbG90czsgY3VsdGNhY2hlLXB5IG5vdyBleHBvc2VzIGRlZmluZV9kYXRhYmFzZV9lbnRyeV90eXBlIGZvciB0aGF0IGNvbnRyYWN0IGFuZCBDdWx0TGliIGdlbmVyYXRvciB3YXMgcGF0Y2hlZCB0byBwcmVzZXJ2ZSBzcGFyc2Uga2V5IHNsb3RzLiIsICJzdGF0dXMiOiAiYWNjZXB0ZWQiLCAidHMiOiAiMjAyNi0wNS0wNlQwMzo1NzoxMCswMDowMCIsICJ0eXBlIjogImFyY2hpdGVjdHVyZSJ9", "stored_at": "2026-05-06T03:57:10.429056+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-05-06T044920Z0000-0067-d18749ed9b85", "payload": "eyJub3RlIjogIkdob3N0bGlnaHQgYWdlbnQtc3RhdGUgc2NoZW1hIGlzIG5vdyB0aGUgZmlyc3Qgc2hhcmVkIGNyb3NzLXJ1bnRpbWUgcGF5bG9hZCBjb250cmFjdC4gQ3VsdE5ldCBjbGllbnRzIHNob3VsZCBjYXJyeSBnaG9zdGxpZ2h0LmFnZW50X3N0YXRlLnYwIHVuY2hhbmdlZCBpbnNpZGUgdHlwZWQgZG9jdW1lbnQgZW52ZWxvcGVzLCB3aXRoIEN1bHRMaWItc3R5bGUgYXV0aC9zZXNzaW9uIHNlbWFudGljcyBoYW5kbGVkIGJ5IHRoZSB0cmFuc3BvcnQgbGF5ZXIgcmF0aGVyIHRoYW4gYmVzcG9rZSBwZXItcmVwbyBnbHVlLiIsICJzdGF0dXMiOiAiYWNjZXB0ZWQiLCAidHMiOiAiMjAyNi0wNS0wNlQwNDo0OToyMCswMDowMCIsICJ0eXBlIjogImFyY2hpdGVjdHVyZSJ9", "stored_at": "2026-05-06T04:49:20.304695+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-05-29T150733Z0000-0068-c7660b1fa54e", "payload": "eyJub3RlIjogIkdob3N0bGlnaHQgbm8gbG9uZ2VyIHRyZWF0cyBnaG9zdGxpZ2h0LmFnZW50X3N0YXRlLnYwIGFzIHRoZSBzaGFyZWQgcGVyc29uLXN0YXRlIHdpcmUgY29udHJhY3QuIEl0IG1pcnJvcnMgZ2FtZWN1bHQucGVyc29uYV9zdGF0ZS52MCBmb3IgcG9ydGFibGUgUGVyc29uYSBwcm9qZWN0aW9ucyB3aGlsZSBrZWVwaW5nIGdob3N0bGlnaHQuYWdlbnRfc3RhdGUudjAgYXMgdGhlIHNjZW5lL3dvcmxkL3N0b3J5IHN0YXRlIGNvbnRyYWN0LiIsICJzdGF0dXMiOiAiYWNjZXB0ZWQiLCAidHMiOiAiMjAyNi0wNS0yOVQxNTowNzozMyswMDowMCIsICJ0eXBlIjogImFyY2hpdGVjdHVyZSJ9", "stored_at": "2026-05-29T15:07:33.580272+00:00", "type": "ghostlight.evidence_record.v0"} diff --git a/state/map.yaml b/state/map.yaml index 8858fa2..b9e9499 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -62,7 +62,8 @@ live_architecture: - `state/ghostlight-state.cultcache.jsonl` is the current CultCache-backed store for migrated branch and evidence records. - `state/branches.json` and `state/evidence.jsonl` remain readable export surfaces during migration so humans, diffs, and older validators do not lose the plot. - New persistent state seams should use `tools/ghostlight_state_store.py` instead of direct file writes. - - `schemas/agent-state.schema.json` is also the first shared cross-runtime payload contract. CultNet clients should move `ghostlight.agent_state.v0` documents unchanged inside typed document envelopes instead of inventing forked state ontologies per repo. + - `schemas/agent-state.schema.json` remains Ghostlight's scene/world state contract; it is no longer the shared person-state wire contract. + - `schemas/gamecult.persona_state.v0.schema.json` mirrors the shared PersonaState contract used by Epiphany public Faces, VoidBot repo Faces, and Ghostlight character projections. CultNet clients should carry PersonaState as `gamecult.persona-state` documents when they need a portable public-person payload. state_model: - Layer personality through organization, disposition, behavior, presentation, situational activation, relationships, goals, and values. - Canonical authored state has no certainty; perceived overlays carry observed activation, attributed disposition, and confidence. diff --git a/tools/validate_persona_state.py b/tools/validate_persona_state.py new file mode 100644 index 0000000..86b4858 --- /dev/null +++ b/tools/validate_persona_state.py @@ -0,0 +1,197 @@ +"""Validate GameCult PersonaState examples. + +This is a focused dependency-free smoke validator. The JSON Schema remains the +canonical contract; this keeps examples honest in normal repo workflows without +dragging a validator dependency into Ghostlight. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_EXAMPLES = sorted((ROOT / "examples").glob("persona-state*.json")) +SCHEMA_VERSION = "gamecult.persona_state.v0" +TARGET_KINDS = { + "person", + "repo", + "scene", + "system", + "room", + "artifact", + "concept", + "relationship", + "self", + "community", + "thread", + "document", + "runtime", + "custom", +} + + +class ValidationError(Exception): + pass + + +def load_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValidationError(message) + + +def require_keys(obj: dict[str, Any], keys: list[str], path: str) -> None: + missing = [key for key in keys if key not in obj] + require(not missing, f"{path} missing required keys: {', '.join(missing)}") + + +def require_timestamp(value: Any, path: str) -> None: + require(isinstance(value, str) and value, f"{path} must be a timestamp string") + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + datetime.fromisoformat(normalized) + except ValueError as error: + raise ValidationError(f"{path} must be ISO-8601 parseable: {value}") from error + + +def require_0_1(value: Any, path: str) -> None: + require(isinstance(value, (int, float)), f"{path} must be numeric") + require(0 <= value <= 1, f"{path} must be between 0 and 1") + + +def validate_target(obj: Any, path: str) -> None: + require(isinstance(obj, dict), f"{path} must be an object") + require_keys(obj, ["kind", "id"], path) + require(obj["kind"] in TARGET_KINDS, f"{path}.kind is not a shared PersonaState target kind") + if obj["kind"] == "custom": + require(isinstance(obj.get("customKind"), str) and obj["customKind"], f"{path}.customKind is required for custom targets") + + +def validate_trait_map(obj: Any, path: str) -> None: + require(isinstance(obj, dict), f"{path} must be an object") + for name, vector in obj.items(): + vector_path = f"{path}.{name}" + require(isinstance(vector, dict), f"{vector_path} must be an object") + require_keys(vector, ["mean", "plasticity", "currentActivation"], vector_path) + require("current_activation" not in vector, f"{vector_path}.current_activation is a native Ghostlight/VoidBot field; PersonaState uses currentActivation") + for key in ["mean", "plasticity", "currentActivation"]: + require_0_1(vector[key], f"{vector_path}.{key}") + + +def validate_activation_profile(obj: Any, path: str) -> None: + require(isinstance(obj, dict), f"{path} must be an object") + groups = [ + "underlyingOrganization", + "stableDispositions", + "behavioralDimensions", + "presentationStrategy", + "voiceStyle", + "situationalState", + ] + require_keys(obj, groups, path) + for group in groups: + validate_trait_map(obj[group], f"{path}.{group}") + + +def validate_anchored_thought(obj: Any, path: str) -> None: + require(isinstance(obj, dict), f"{path} must be an object") + require_keys(obj, ["id", "status", "target", "summary", "tension", "actionImplication", "createdAt", "updatedAt"], path) + require(obj["status"] in {"draft", "active", "cooling", "crystallized", "resolved", "retired"}, f"{path}.status is invalid") + validate_target(obj["target"], f"{path}.target") + require_timestamp(obj["createdAt"], f"{path}.createdAt") + require_timestamp(obj["updatedAt"], f"{path}.updatedAt") + if "retiredAt" in obj: + require_timestamp(obj["retiredAt"], f"{path}.retiredAt") + if "intensity" in obj: + require_0_1(obj["intensity"], f"{path}.intensity") + if "valence" in obj: + require(isinstance(obj["valence"], (int, float)), f"{path}.valence must be numeric") + require(-1 <= obj["valence"] <= 1, f"{path}.valence must be between -1 and 1") + + +def validate_candidate_action(obj: Any, path: str) -> None: + require(isinstance(obj, dict), f"{path} must be an object") + require_keys(obj, ["id", "status", "actionType", "readiness", "riskLevel", "target", "summary", "createdAt", "updatedAt"], path) + validate_target(obj["target"], f"{path}.target") + if "deliveryTarget" in obj: + validate_target(obj["deliveryTarget"], f"{path}.deliveryTarget") + require_timestamp(obj["createdAt"], f"{path}.createdAt") + require_timestamp(obj["updatedAt"], f"{path}.updatedAt") + if obj["actionType"] == "custom": + require(isinstance(obj.get("customActionType"), str) and obj["customActionType"], f"{path}.customActionType is required for custom actions") + + +def validate_document(document: dict[str, Any], source: Path) -> None: + require(document.get("schemaVersion") == SCHEMA_VERSION, f"{source} has wrong schemaVersion") + require_keys( + document, + [ + "provenance", + "personaId", + "publicName", + "presentation", + "activationProfile", + "thoughtMemory", + "agencyPressure", + "candidateActions", + "affect", + "updatedAt", + ], + str(source), + ) + provenance = document["provenance"] + require_keys(provenance, ["sourceSystem", "sourceDocumentId", "sourceUpdatedAt", "exportedAt", "authority"], f"{source}.provenance") + require(provenance["authority"] in {"canonical", "projection", "import"}, f"{source}.provenance.authority is invalid") + require_timestamp(provenance["sourceUpdatedAt"], f"{source}.provenance.sourceUpdatedAt") + require_timestamp(provenance["exportedAt"], f"{source}.provenance.exportedAt") + require_keys(document["presentation"], ["voiceSummary"], f"{source}.presentation") + if "homeContext" in document["presentation"]: + validate_target(document["presentation"]["homeContext"], f"{source}.presentation.homeContext") + + validate_activation_profile(document["activationProfile"], f"{source}.activationProfile") + for group_name in ["shortTerm", "memories", "incubation"]: + entries = document["thoughtMemory"].get(group_name) + require(isinstance(entries, list), f"{source}.thoughtMemory.{group_name} must be an array") + for index, thought in enumerate(entries): + validate_anchored_thought(thought, f"{source}.thoughtMemory.{group_name}[{index}]") + for index, pressure in enumerate(document["agencyPressure"].get("pressures", [])): + validate_anchored_thought(pressure, f"{source}.agencyPressure.pressures[{index}]") + for index, action in enumerate(document["candidateActions"].get("actions", [])): + validate_candidate_action(action, f"{source}.candidateActions.actions[{index}]") + if "voidbotProjection" in document: + for index, action in enumerate(document["voidbotProjection"].get("candidateInterventions", [])): + validate_candidate_action(action, f"{source}.voidbotProjection.candidateInterventions[{index}]") + + affect = document["affect"] + for key in ["needs", "socialBonds", "statusReads", "moodDimensions", "socialBiases", "doctrineStances"]: + require(isinstance(affect.get(key), list), f"{source}.affect.{key} must be an array") + for index, need in enumerate(affect["needs"]): + validate_anchored_thought(need, f"{source}.affect.needs[{index}]") + require_timestamp(document["updatedAt"], f"{source}.updatedAt") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate GameCult PersonaState examples.") + parser.add_argument("paths", nargs="*", type=Path, help="PersonaState JSON files to validate.") + args = parser.parse_args() + paths = args.paths or DEFAULT_EXAMPLES + + require(paths, "No PersonaState examples found.") + for path in paths: + validate_document(load_json(path), path) + print(f"ok: {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c163b4ca802460f6a53912401c2c8d34aa3c6529 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 2 Jun 2026 18:49:47 +0100 Subject: [PATCH 3/5] Map Ghostlight Verse service surface --- README.md | 2 + docs/architecture/verse-service-contract.md | 88 +++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 docs/architecture/verse-service-contract.md diff --git a/README.md b/README.md index 966bdc9..4d891f8 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ are stable. - `docs/research/`: research backlog and modeling notes - `docs/aetheria/`: Aetheria source material used by Ghostlight - `docs/architecture/`: state, projection, and authoring architecture +- `docs/architecture/verse-service-contract.md`: GameCult Verse service contract + for Ghostlight's missing Eve GUI/TUI state and authoring surface - `schemas/`: JSON schema contracts and required field lists - `examples/`: schema fixtures and future projection examples - `tools/ghostlight_state.py`: state inspection and evidence helper diff --git a/docs/architecture/verse-service-contract.md b/docs/architecture/verse-service-contract.md new file mode 100644 index 0000000..57a0c2d --- /dev/null +++ b/docs/architecture/verse-service-contract.md @@ -0,0 +1,88 @@ +# Ghostlight Verse Service Contract + +Ghostlight is a state/projection service candidate for socially persistent +characters and simulated people. Its durable truth is not a dashboard and not a +prompt. Its durable truth is typed character, scene, relationship, event, +perception, and Persona projection state. + +Ghostlight already has a CultCache-backed persistence seam. The next GameCult +Verse cut is to expose its inspection and authoring surfaces as Eve GUI/TUI DSL +so local runtimes can browse state, projection, and review artifacts without +turning Ghostlight into a private web app. + +## Owner Map + +- Owner: Ghostlight owns canonical scene/world/agent state, observer-specific + perceived state, projection inputs, reviewed mutation receipts, training-loop + bundles, and Persona projections derived from story agents. +- Inputs: Aetheria lore/source context, canonical agent and scene state, + projected local context, responder packets, sandboxed responder output, + reviewer receipts, participant appraisals, and accepted mutation receipts. +- Outputs: CultCache-backed state records, readable migration exports, reviewed + training artifacts, schema fixtures, event records, participant appraisals, + and portable `gamecult.persona_state.v0` projections when a public + person-shaped surface is needed. +- Derived state: `state/branches.json`, `state/evidence.jsonl`, validator + reports, fixture sidecars, and authoring previews are compatibility or review + surfaces, not long-term direct-write APIs. +- Forbidden writers: authoring dashboards, visual replay applets, prompt + renderers, review helpers, and Eve/TUI renderers must not directly mutate + canonical character truth. They emit reviewed mutation intent or read typed + projections. +- Shared paths: state CLI, fixture validators, training-loop review, future + authoring UI, future Eve surface, and future compact TUI must read the same + CultCache-backed state seam instead of maintaining separate dashboard truth. +- Deletion line: any direct-write JSON state path that survives after a typed + document exists must be demoted to export/witness or removed. + +## Current State + +Ghostlight already has these substrate pieces: + +- `state/ghostlight-state.cultcache.jsonl` as the current CultCache-backed + migration spine; +- `vendor/cultcache-py`; +- `tools/ghostlight_state_store.py` as the state access seam; +- `schemas/gamecult.persona_state.v0.schema.json` as the mirrored portable + Persona contract; +- `schemas/agent-state.schema.json` for Ghostlight-native scene/world/story + state; +- architecture docs for persistence, projection, training loops, prompt + projection, and shared Persona/CultNet contracts. + +The missing surface is not another readable export. The missing surface is a +provider-owned Eve GUI/TUI composition over the existing state and review seams. + +## Eve Surface Target + +Ghostlight should publish an Eve GUI/TUI DSL surface with these panels: + +1. `State Spine`: CultCache store path, document families, migration status, + readable export freshness, and direct-write debt. +2. `Scene And Agent State`: active scene fixtures, canonical agent state, + observer/perceived overlays, and relationship stance availability. +3. `Projection Pipeline`: projected local context, responder packet, responder + output, review status, leakage audit, and mutation receipt state. +4. `Training Coverage`: accepted/draft/planned/rejected fixture counts, + coverage gaps, tonal modes, collision axes, and training target families. +5. `Persona Projection`: `gamecult.persona_state.v0` availability for public + character/person surfaces, with provenance back to Ghostlight-native state. + +Each panel must expose source provenance and freshness. If a panel is reading a +readable JSON export during migration, the surface should say that the export is +not the canonical owner. + +## Migration Order + +1. Promote remaining direct-write state paths behind `tools/ghostlight_state_store.py` + or its successor typed document seam. +2. Define a `ghostlight.eve_surface.v0` provider contract over the current + state, projection, review, and Persona documents. +3. Add a read-only command that lowers those documents into Eve DSL. +4. Publish the surface through CultMesh/Odin discovery. +5. Let browser/native/TUI runtimes lower the same surface. +6. Demote old preview/status dashboards to renderers of the provider-owned Eve + surface. + +The invariant: Ghostlight owns story/person state. Eve makes it inspectable. +Authoring surfaces request reviewed mutations; they do not become state owners. From 96b8edfc493ae8427f1307bd11fb600159f04853 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 3 Jun 2026 03:46:23 +0100 Subject: [PATCH 4/5] Add Ghostlight provider advertisement witness --- .../ghostlight-provider-advertisement.v0.json | 285 ++++++++++++++++++ package.json | 1 + tools/ghostlight_state.py | 27 ++ 3 files changed, 313 insertions(+) create mode 100644 examples/ghostlight-provider-advertisement.v0.json diff --git a/examples/ghostlight-provider-advertisement.v0.json b/examples/ghostlight-provider-advertisement.v0.json new file mode 100644 index 0000000..0433412 --- /dev/null +++ b/examples/ghostlight-provider-advertisement.v0.json @@ -0,0 +1,285 @@ +{ + "schema_version": "ghostlight.provider_advertisement.v0", + "provider_id": "ghostlight", + "service_name": "Ghostlight", + "authority": { + "owner": "Ghostlight", + "mode": "read_only_advertisement", + "canonical_truth": [ + "scene state", + "world state", + "agent state", + "relationship state", + "event state", + "observer-specific perceived state", + "projection inputs", + "reviewed mutation receipts", + "Persona projections derived from story agents" + ], + "non_owners": [ + "Eve renderers", + "authoring dashboards", + "visual replay applets", + "prompt renderers", + "review helpers" + ], + "contract_doc": "docs/architecture/verse-service-contract.md" + }, + "schemas": [ + { + "schema_version": "ghostlight.agent_state.v0", + "path": "schemas/agent-state.schema.json", + "owns": [ + "scene/world/story state", + "agents", + "relationships", + "events", + "observer-specific perceived overlays", + "dialogue context packs" + ] + }, + { + "schema_version": "ghostlight.agent_state.required_fields.v0", + "path": "schemas/agent-state.required-fields.json", + "owns": [ + "required canonical variable groups", + "required relationship stance fields" + ] + }, + { + "schema_version": "gamecult.persona_state.v0", + "path": "schemas/gamecult.persona_state.v0.schema.json", + "owns": [ + "portable public-person projection", + "presentation", + "values", + "activation profile", + "thought memory", + "agency pressure", + "candidate actions", + "affect", + "source provenance" + ], + "stewardship": "Huginn stewards shared Persona schema availability, migration pressure, .cc inspection, CultMesh publication, and Eve DSL inspection output." + } + ], + "cultcache_witnesses": [ + { + "document_type": "ghostlight.branches.v0", + "witness_path": "state/ghostlight-state.cultcache.jsonl", + "readable_export": "state/branches.json", + "authority": "compatibility export is readable witness only; Ghostlight state seam owns writes" + }, + { + "document_type": "ghostlight.evidence_record.v0", + "witness_path": "state/ghostlight-state.cultcache.jsonl", + "readable_export": "state/evidence.jsonl", + "authority": "compatibility export is readable witness only; Ghostlight state seam owns writes", + "indexes": [ + "type", + "status", + "branch" + ] + } + ], + "persona_witnesses": [ + { + "document_type": "gamecult.persona-state", + "payload_schema_version": "gamecult.persona_state.v0", + "fixture_path": "examples/persona-state.call-of-the-void.oz.json", + "schema_path": "schemas/gamecult.persona_state.v0.schema.json", + "authority": "portable projection witness; native Ghostlight scene state remains richer and canonical" + } + ], + "nested_verses": [ + { + "verse_id": "ghostlight.scene", + "contains": [ + "world canon context", + "scene records", + "visible participants", + "public stakes", + "event records", + "reviewed mutation receipts" + ], + "source_examples": [ + "examples/agent-state.call-of-the-void.json", + "examples/agent-state.pallas-species-strikes.v0.json", + "examples/training-loops" + ] + }, + { + "verse_id": "ghostlight.agent", + "contains": [ + "identity", + "canonical_state", + "goals", + "memories", + "relationships", + "perceived_state_overlays", + "dialogue_context_packs" + ], + "source_examples": [ + "examples/agent-state.call-of-the-void.json", + "examples/agent-state.pallas-species-strikes.v0.json" + ] + }, + { + "verse_id": "ghostlight.persona", + "contains": [ + "gamecult.persona_state.v0 projections", + "portable person-shaped state", + "projection provenance" + ], + "source_examples": [ + "examples/persona-state.call-of-the-void.oz.json" + ] + } + ], + "cultmesh_publication": { + "provider_contract": "ghostlight.provider_advertisement.v0", + "intended_route": "CultMesh/Odin discovery", + "publication_mode": "read_only_provider_advertisement", + "state_authority": "Ghostlight-owned typed documents; CultMesh carries discovery and projected surfaces, not separate truth" + }, + "eve_surfaces": [ + { + "surface_id": "ghostlight.state_spine", + "title": "State Spine", + "panels": [ + "CultCache store path", + "document families", + "migration status", + "readable export freshness", + "direct-write debt" + ] + }, + { + "surface_id": "ghostlight.scene_and_agent_state", + "title": "Scene And Agent State", + "panels": [ + "active scene fixtures", + "canonical agent state", + "observer/perceived overlays", + "relationship stance availability" + ] + }, + { + "surface_id": "ghostlight.projection_pipeline", + "title": "Projection Pipeline", + "panels": [ + "projected local context", + "responder packet", + "responder output", + "review status", + "leakage audit", + "mutation receipt state" + ] + }, + { + "surface_id": "ghostlight.training_coverage", + "title": "Training Coverage", + "panels": [ + "accepted/draft/planned/rejected fixture counts", + "coverage gaps", + "tonal modes", + "collision axes", + "training target families" + ] + }, + { + "surface_id": "ghostlight.persona_projection", + "title": "Persona Projection", + "panels": [ + "gamecult.persona_state.v0 availability", + "public character/person surfaces", + "provenance back to Ghostlight-native state" + ] + } + ], + "command_boundaries": { + "read_only": [ + "status", + "provider-advertisement", + "fixture validators", + "future Eve DSL lowering", + "future CultMesh provider discovery" + ], + "reviewed_mutation_intent_only": [ + "future authoring UI", + "future compact TUI command surface", + "review helpers" + ], + "write_authority": [ + "tools/ghostlight_state_store.py registered document seam", + "reviewed mutation receipt acceptance path" + ], + "forbidden_direct_writers": [ + "Eve GUI renderer", + "Eve TUI renderer", + "browser dashboard", + "visual replay applet", + "prompt renderer", + "unreviewed responder output" + ] + }, + "style_capabilities": { + "source": "schemas/agent-state.required-fields.json and docs/architecture/canonical-agent-state-schema.md", + "voice_style_dimensions": [ + "dryness", + "verbal_warmth", + "formality", + "verbosity", + "pace", + "plainspoken_directness", + "lexical_precision", + "technical_density", + "technical_compression", + "figurative_language", + "lyricism", + "narrative_detail", + "emotional_explicitness", + "pointedness", + "self_disclosure", + "hedging", + "certainty_marking", + "politeness", + "coded_politeness", + "ritualized_address", + "register_switching", + "dialect_marking", + "theatricality", + "humor", + "conversational_dominance", + "listening_responsiveness", + "question_asking", + "profanity" + ], + "style_derivation": [ + "presentation_strategy", + "voice_style", + "situational_state", + "underlying_organization", + "behavioral_dimensions", + "relationship stance", + "observer-specific perceived overlays", + "dialogue context packs" + ], + "invariant": "style is derived from typed character state and local context; renderers do not own personality truth" + }, + "huginn_stewardship": { + "owns": [ + "Persona-state schema availability", + ".cc inspection pressure", + "projection health inspection", + "access-tool sanity", + "CultMesh publication coordination", + "Eve DSL inspection output" + ], + "does_not_own": [ + "Ghostlight native story state", + "Eve presentation runtime", + "browser/native/TUI renderer truth" + ] + } +} diff --git a/package.json b/package.json index 3d16027..49abf2f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Persistent research repo for socially situated generative agent architecture.", "scripts": { "state:status": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py status", + "provider:advertisement": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py provider-advertisement", "state:prepare-compaction": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_prepare_compaction.py", "agent-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_agent_state.py", "persona-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_persona_state.py", diff --git a/tools/ghostlight_state.py b/tools/ghostlight_state.py index f833bbe..8809783 100644 --- a/tools/ghostlight_state.py +++ b/tools/ghostlight_state.py @@ -15,6 +15,9 @@ MAP_PATH = STATE_DIR / "map.yaml" BRANCHES_PATH = STATE_DIR / "branches.json" EVIDENCE_PATH = STATE_DIR / "evidence.jsonl" COVERAGE_PATH = STATE_DIR / "corpus-coverage.json" +PROVIDER_ADVERTISEMENT_FIXTURE_PATH = ( + ROOT / "examples" / "ghostlight-provider-advertisement.v0.json" +) def read_text(path: Path) -> str: @@ -72,6 +75,20 @@ def load_coverage_summary() -> str | None: return f"{len(accepted)} accepted fixtures" +def cmd_provider_advertisement(args: argparse.Namespace) -> int: + content = read_text(PROVIDER_ADVERTISEMENT_FIXTURE_PATH) + json.loads(content) + if args.output: + output_path = (ROOT / args.output).resolve() + if ROOT not in output_path.parents and output_path != ROOT: + raise SystemExit("Output path must stay inside the Ghostlight repo.") + output_path.write_text(content, encoding="utf-8") + print(f"Wrote provider advertisement to {output_path}") + else: + print(content, end="") + return 0 + + def cmd_status(_: argparse.Namespace) -> int: branches = load_branches().get("branches", []) active = [branch for branch in branches if branch.get("status") == "active"] @@ -149,6 +166,16 @@ def build_parser() -> argparse.ArgumentParser: status_parser = subparsers.add_parser("status", help="Show a compact state summary.") status_parser.set_defaults(func=cmd_status) + provider_parser = subparsers.add_parser( + "provider-advertisement", + help="Export Ghostlight's read-only CultMesh/Eve provider advertisement.", + ) + provider_parser.add_argument( + "--output", + help="Repo-relative path to write instead of printing to stdout.", + ) + provider_parser.set_defaults(func=cmd_provider_advertisement) + evidence_parser = subparsers.add_parser( "add-evidence", help="Append one distilled JSONL evidence record." ) From 06b37618ceda36710361a21aacc02f8fd4428cef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 23 Jun 2026 23:31:04 +0100 Subject: [PATCH 5/5] Publish Ghostlight provider respect to Odin --- docs/architecture/verse-service-contract.md | 6 +- package.json | 1 + tools/ghostlight_state.py | 52 ++++++ tools/publish_odin_provider_advertisement.cjs | 175 ++++++++++++++++++ 4 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tools/publish_odin_provider_advertisement.cjs diff --git a/docs/architecture/verse-service-contract.md b/docs/architecture/verse-service-contract.md index 57a0c2d..b84ed11 100644 --- a/docs/architecture/verse-service-contract.md +++ b/docs/architecture/verse-service-contract.md @@ -49,6 +49,10 @@ Ghostlight already has these substrate pieces: state; - architecture docs for persistence, projection, training loops, prompt projection, and shared Persona/CultNet contracts. +- `tools/ghostlight_state.py publish-odin` and + `tools/publish_odin_provider_advertisement.cjs` publish Ghostlight's + provider advertisement once to Odin's CultMesh/RUDP document ingress when an + endpoint is supplied. The missing surface is not another readable export. The missing surface is a provider-owned Eve GUI/TUI composition over the existing state and review seams. @@ -79,7 +83,7 @@ not the canonical owner. 2. Define a `ghostlight.eve_surface.v0` provider contract over the current state, projection, review, and Persona documents. 3. Add a read-only command that lowers those documents into Eve DSL. -4. Publish the surface through CultMesh/Odin discovery. +4. Publish the provider advertisement through CultMesh/Odin discovery. 5. Let browser/native/TUI runtimes lower the same surface. 6. Demote old preview/status dashboards to renderers of the provider-owned Eve surface. diff --git a/package.json b/package.json index 49abf2f..4b10e9a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "state:status": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py status", "provider:advertisement": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py provider-advertisement", + "provider:publish-odin": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_state.py publish-odin", "state:prepare-compaction": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\ghostlight_prepare_compaction.py", "agent-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_agent_state.py", "persona-state:validate": "\"C:\\Users\\Meta\\.cache\\codex-runtimes\\codex-primary-runtime\\dependencies\\python\\python.exe\" .\\tools\\validate_persona_state.py", diff --git a/tools/ghostlight_state.py b/tools/ghostlight_state.py index 8809783..3203764 100644 --- a/tools/ghostlight_state.py +++ b/tools/ghostlight_state.py @@ -2,6 +2,9 @@ from __future__ import annotations import argparse import json +import os +import subprocess +import sys from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -18,6 +21,17 @@ COVERAGE_PATH = STATE_DIR / "corpus-coverage.json" PROVIDER_ADVERTISEMENT_FIXTURE_PATH = ( ROOT / "examples" / "ghostlight-provider-advertisement.v0.json" ) +ODIN_PROVIDER_PUBLISHER_PATH = ROOT / "tools" / "publish_odin_provider_advertisement.cjs" +DEFAULT_NODE_PATH = ( + Path.home() + / ".cache" + / "codex-runtimes" + / "codex-primary-runtime" + / "dependencies" + / "node" + / "bin" + / ("node.exe" if os.name == "nt" else "node") +) def read_text(path: Path) -> str: @@ -89,6 +103,29 @@ def cmd_provider_advertisement(args: argparse.Namespace) -> int: return 0 +def cmd_publish_odin(args: argparse.Namespace) -> int: + endpoint = args.odin_cultmesh_rudp or os.environ.get("GHOSTLIGHT_ODIN_CULTMESH_RUDP", "") + if not endpoint: + raise SystemExit( + "Missing Odin endpoint. Pass --odin-cultmesh-rudp host:port or set GHOSTLIGHT_ODIN_CULTMESH_RUDP." + ) + + node = Path(args.node or os.environ.get("NODE", "") or DEFAULT_NODE_PATH) + if not node.exists(): + raise SystemExit(f"Node runtime not found: {node}") + + command = [ + str(node), + str(ODIN_PROVIDER_PUBLISHER_PATH), + "--odin-cultmesh-rudp", + endpoint, + "--fixture", + str(PROVIDER_ADVERTISEMENT_FIXTURE_PATH.relative_to(ROOT)), + ] + result = subprocess.run(command, cwd=ROOT, text=True) + return result.returncode + + def cmd_status(_: argparse.Namespace) -> int: branches = load_branches().get("branches", []) active = [branch for branch in branches if branch.get("status") == "active"] @@ -176,6 +213,21 @@ def build_parser() -> argparse.ArgumentParser: ) provider_parser.set_defaults(func=cmd_provider_advertisement) + publish_odin_parser = subparsers.add_parser( + "publish-odin", + help="Publish Ghostlight's provider advertisement once to Odin over CultMesh/RUDP.", + ) + publish_odin_parser.add_argument( + "--odin-cultmesh-rudp", + dest="odin_cultmesh_rudp", + help="Odin CultMesh/RUDP document ingress endpoint as host:port.", + ) + publish_odin_parser.add_argument( + "--node", + help="Node executable to use for the CultMesh runtime publisher.", + ) + publish_odin_parser.set_defaults(func=cmd_publish_odin) + evidence_parser = subparsers.add_parser( "add-evidence", help="Append one distilled JSONL evidence record." ) diff --git a/tools/publish_odin_provider_advertisement.cjs b/tools/publish_odin_provider_advertisement.cjs new file mode 100644 index 0000000..f982e3a --- /dev/null +++ b/tools/publish_odin_provider_advertisement.cjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const Module = require("node:module"); +const path = require("node:path"); + +const repoRoot = path.resolve(__dirname, ".."); +process.env.NODE_PATH = [ + path.resolve(repoRoot, "..", "CultLib", "packages"), + process.env.NODE_PATH || "", +].filter(Boolean).join(path.delimiter); +Module._initPaths(); + +const { defineDocumentType } = require("cultcache-ts"); +const { CultMesh } = require("cultmesh-ts"); +const { defineCultNetDocumentBinding } = require("cultnet-ts"); + +const providerAdvertisementDefinition = defineDocumentType({ + type: "gamecult.eve.provider_advertisement", + schemaName: "gamecult.eve.provider_advertisement", + schemaId: "gamecult.eve.provider_advertisement.v1", + schemaVersion: "gamecult.eve.provider_advertisement.v1", + global: true, + name: (value) => value?.providerId || "ghostlight", + schema: { parse: (value) => value }, + members: [ + { slot: 0, memberName: "providerId", typeName: "string", isName: true }, + { slot: 1, memberName: "serviceId", typeName: "string" }, + { slot: 2, memberName: "verseId", typeName: "string" }, + { slot: 3, memberName: "title", typeName: "string" }, + { slot: 4, memberName: "description", typeName: "string" }, + { slot: 5, memberName: "canonicalService", typeName: "string" }, + { slot: 6, memberName: "locatedService", typeName: "string" }, + { slot: 7, memberName: "cultMeshAddress", typeName: "string" }, + { slot: 8, memberName: "status", typeName: "string" }, + { slot: 9, memberName: "updatedAt", typeName: "string" }, + { slot: 10, memberName: "capabilities", typeName: "array" }, + { slot: 11, memberName: "endpoints", typeName: "array" }, + { slot: 12, memberName: "routes", typeName: "array" }, + ], +}); + +main().catch((error) => { + console.error(error?.stack || error?.message || String(error)); + process.exitCode = 1; +}); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const endpoint = args["odin-cultmesh-rudp"] || process.env.GHOSTLIGHT_ODIN_CULTMESH_RUDP || ""; + if (!endpoint) { + throw new Error("Ghostlight Odin publication requires --odin-cultmesh-rudp or GHOSTLIGHT_ODIN_CULTMESH_RUDP."); + } + + const fixturePath = path.resolve(repoRoot, args.fixture || "examples/ghostlight-provider-advertisement.v0.json"); + if (!isInside(repoRoot, fixturePath)) { + throw new Error("Ghostlight provider fixture path must stay inside the repo."); + } + + const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + const advertisement = buildOdinAdvertisement(fixture, fixturePath); + await CultMesh.publishRudpDocumentOnce( + "ghostlight", + 0x0d1d0002, + normalizeRudpEndpoint(endpoint), + defineCultNetDocumentBinding({ definition: providerAdvertisementDefinition }), + advertisement.providerId, + advertisement, + { + sourceRole: "ghostlight.provider", + tags: ["startup-respect", "odin-verse-discovery"], + }, + ); + + console.log(JSON.stringify({ + ok: true, + schemaId: "gamecult.eve.provider_advertisement.v1", + providerId: advertisement.providerId, + odinCultMeshRudp: endpoint, + }, null, 2)); +} + +function buildOdinAdvertisement(fixture, fixturePath) { + const observedAt = new Date().toISOString(); + const providerId = fixture.provider_id || "ghostlight"; + const surfaces = Array.isArray(fixture.eve_surfaces) ? fixture.eve_surfaces : []; + const schemas = Array.isArray(fixture.schemas) ? fixture.schemas : []; + const witnesses = Array.isArray(fixture.cultcache_witnesses) ? fixture.cultcache_witnesses : []; + return { + providerId, + serviceId: "ghostlight.social-agent-state", + verseId: "starfire.local", + title: fixture.service_name || "Ghostlight", + description: "Socially persistent generative-agent state/projection research service candidate.", + canonicalService: "asgard.ghostlight", + locatedService: "asgard.starfire.ghostlight", + cultMeshAddress: "asgard.starfire.ghostlight/state", + status: "read-only-provider-advertisement", + updatedAt: observedAt, + capabilities: [ + "social-agent-state", + "persona-state-projection", + "training-fixture-review", + "cultcache-witness", + "eve-surface-target", + ], + endpoints: [ + { transport: "cultcache-witness", address: "state/ghostlight-state.cultcache.jsonl" }, + { transport: "repo-fixture", address: path.relative(repoRoot, fixturePath).replace(/\\/g, "/") }, + ], + routes: [ + { transport: "repo-cli", address: "npm run provider:advertisement", role: "provider-advertisement" }, + { transport: "repo-cli", address: "npm run state:status", role: "status" }, + ], + schemaCatalog: schemas.map((schema) => ({ + schemaId: schema.schema_version || schema.document_type || "unknown", + path: schema.path || "", + owns: Array.isArray(schema.owns) ? schema.owns : [], + })), + witnessCatalog: witnesses.map((witness) => ({ + schemaId: witness.document_type || "unknown", + path: witness.witness_path || "", + readableExport: witness.readable_export || "", + authority: witness.authority || "", + })), + surfaces: surfaces.map((surface) => ({ + id: surface.surface_id || "ghostlight.surface", + title: surface.title || surface.surface_id || "Ghostlight Surface", + panels: Array.isArray(surface.panels) ? surface.panels : [], + })), + commandSurface: { + mode: "read-only", + commands: ["status", "provider-advertisement", "publish-odin"], + mutationPolicy: "reviewed mutation intent only", + }, + authority: fixture.authority || {}, + ghostlightProviderAdvertisement: fixture, + }; +} + +function normalizeRudpEndpoint(value) { + const text = String(value || "").trim(); + if (!text) throw new Error("Odin CultMesh/RUDP endpoint must be non-empty."); + if (text.startsWith("rudp://")) return text; + const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/); + if (ipv6) return `rudp://[${ipv6[1]}]:${ipv6[2]}`; + const index = text.lastIndexOf(":"); + if (index <= 0 || index === text.length - 1) { + throw new Error(`Odin CultMesh/RUDP endpoint must be host:port, got "${value}".`); + } + return `rudp://${text.slice(0, index)}:${text.slice(index + 1)}`; +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = true; + } else { + parsed[key] = next; + index += 1; + } + } + return parsed; +} + +function isInside(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +}