Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
06b37618ce Publish Ghostlight provider respect to Odin 2026-06-23 23:31:04 +01:00
Meta Vens
96b8edfc49 Add Ghostlight provider advertisement witness 2026-06-03 03:46:23 +01:00
Meta Vens
c163b4ca80 Map Ghostlight Verse service surface 2026-06-02 18:49:47 +01:00
Meta Vens
a1ec7ace5d Adopt shared PersonaState contract 2026-05-29 16:09:25 +01:00
Meta Vens
c48dac69a6 Bridge Ghostlight scene initiative to Epiphany heartbeat 2026-05-06 14:33:58 +01:00
19 changed files with 2448 additions and 78 deletions

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,92 @@
# 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.
- `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.
## 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 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.
The invariant: Ghostlight owns story/person state. Eve makes it inspectable.
Authoring surfaces request reviewed mutations; they do not become state owners.

View file

@ -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,

View file

@ -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"
]
}
}

View file

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

View file

@ -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.

View file

@ -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,18 +332,19 @@ 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
- 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.

View file

@ -5,9 +5,12 @@
"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",
"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",
"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",
@ -16,6 +19,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"

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -65,4 +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."}

View file

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

View file

@ -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.
@ -86,7 +87,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 +243,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 <path>` 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.

View file

@ -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 <path> Ghostlight scene-loop initial state JSON.
--agent-state <path> Ghostlight agent-state JSON. Defaults from initial state.
--scene-id <id> Scene heartbeat id. Defaults to scene_loop_id.
--scene-ref <path> Source scene ref for receipts. Defaults to initial-state path.
--store <path> MessagePack heartbeat store. Defaults to ${DEFAULT_STORE}.
--artifact-dir <path> Artifact directory for tick/status projections.
--schedule-id <id> Tick schedule id.
--schedule-out <path> Write tick schedule JSON projection to this path.
--epiphany-root <path> 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);
}

View file

@ -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
@ -15,6 +18,20 @@ 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"
)
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:
@ -72,6 +89,43 @@ 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_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"]
@ -149,6 +203,31 @@ 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)
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."
)

View file

@ -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 <host:port> 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));
}

View file

@ -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":

View file

@ -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())