Add Ghostlight heartbeat initiative seam

This commit is contained in:
Meta Vens 2026-05-05 13:25:40 +01:00
commit d7c98a91ef
10 changed files with 889 additions and 3 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ apps/epiphany-gui/src-tauri/target/
.epiphany-gui/
.epiphany-smoke/
.epiphany-dogfood/
.epiphany-heartbeats/

View file

@ -41,6 +41,7 @@ Do not trust this file for the exact live HEAD. Always check git.
- The prompt doctrine pass is landed. Shared Epiphany prompts now carry distilled memory/evidence discipline. Rendered state intro/doctrine text lives in `epiphany-core/src/prompts/`, and lane/control prompt text lives in `vendor/codex/codex-rs/app-server/src/prompts/epiphany_specialists.toml`: modeling is the Body, implementation is the Hands and GUI-launched main coding lane, verification is the Soul, reorientation is Life, coordinator remains the read-only Self, and CRRC owns the pre-compaction intervention template.
- The Ghostlight memory pass is landed. `epiphany_specialists.toml` now has a shared persistent-memory projection prepended by the harness to fixed role specialists, reorientation workers, coordinator notes, and CRRC checkpoint interventions; `tools/epiphany_gui_action.py` prepends the same memory to GUI-launched implementation prompts. The rendered base doctrine also states the Perfect Machine rule directly: prompt is projection, durable typed state is the mind, every lane must improve its own memory/model/prompt/evidence habit or name the repair, and each lane phrases that duty in its own organ language so the salience sticks.
- The role self-memory persistence pass is landed. Each lane has a Ghostlight-shaped dossier under `state/agents`, and specialists may return optional `selfPatch` requests beside their normal role result. `roleResult`/`roleAccept` project coordinator review as `selfPersistence`: accepted requests are role-matched, bounded lane memory/goal/value/private-note mutations; refused requests explain wrong role, project-truth smuggling, authority grabs, bloat, missing reason, or malformed records. GUI/coordinator accept paths apply accepted `selfPatch` requests through `tools/epiphany_agent_memory.py`; project truth still belongs only in `EpiphanyThreadState`.
- The heartbeat initiative pass is landed as a bounded tool seam. `state/agent-heartbeats.json` tracks Self, Imagination, Eyes, Body, Hands, Soul, and Life as Ghostlight-style initiative participants with speed, next-ready time, reaction bias, interrupt threshold, load, status, and constraints. `tools/epiphany_agent_heartbeat.py` lets the harness set a target heartbeat rate, emits Ghostlight-shaped initiative receipts, pulls pending coordinator work through reaction windows, and makes idle winners ruminate into bounded self-memory instead of inventing project work. This is a callable scheduler seam, not yet an always-on daemon.
- The Aetheria dogfood run has a contamination scar. The supervising Codex session directly edited and committed target-repo work on `E:\Projects\Aetheria-Economy` instead of only driving Epiphany lanes. Treat those Aetheria commits as supervisor-seeded implementation, not clean evidence that Epiphany coordinated the work. Future dogfood must run through the GUI/coordinator/fixed role lanes with auditable artifacts unless the user explicitly authorizes an operator intervention.
- The dogfood quarantine now has a direct-thought boundary. The supervisor may read coordinator actions, role/reorient statuses, structured finding summaries, reviewed state patches, rendered status snapshots, and artifact manifests. It must not read raw worker transcripts, direct worker messages, full turn logs, or `rawResult` payloads during normal dogfood. Those artifacts are sealed for explicit forensic debugging only.
- `tools/epiphany_agent_telemetry.py` is the safe instrument panel for sealed runs. Status/coordinator/GUI/dogfood/live-specialist tools generate telemetry JSON from sealed transcripts that preserves method names, call shape, job/status/path counts, and any visible function/tool names while sealing text, direct messages, and raw results.
@ -129,6 +130,7 @@ The current spine:
- durable investigation checkpoint packet through typed state, prompt, scene, and context
- distilled shared and config-backed prompt doctrine through the base prompt, rendered Epiphany state prompt files, modeling/Body, implementation/Hands, verification/Soul, reorientation/Life, coordinator/Self, and CRRC intervention surfaces
- shared Ghostlight-derived persistent memory projection across Imagination, Body, Soul, Life, Self, CRRC checkpoint steering, and the GUI-launched Hands implementation lane
- Ghostlight-derived heartbeat initiative scheduling through `tools/epiphany_agent_heartbeat.py`, with Self included as a first-class participant and idle rumination routed through normal `selfPatch` review/application
- Ghostlight-shaped role dossiers under `state/agents`, plus `selfPatch` review projection through `roleResult`/`roleAccept` and accepted memory application through `tools/epiphany_agent_memory.py`
- first Unity runtime bridge through `tools/epiphany_unity_bridge.py`, `tools/epiphany_unity_bridge_smoke.py`, GUI Inspect Unity, runtime artifact listing, and implementation prompt guardrails
- first Rider source-context bridge through `tools/epiphany_rider_bridge.py`, `tools/epiphany_rider_bridge_smoke.py`, GUI Inspect Rider, Rider artifact listing, implementation prompt guardrails, and a source scaffold under `integrations/rider`

179
state/agent-heartbeats.json Normal file
View file

@ -0,0 +1,179 @@
{
"schema_version": "epiphany.agent_heartbeat.v0",
"target_heartbeat_rate": 1.0,
"scene_clock": 0.0,
"selection_policy": {
"mode": "readiness_queue",
"reaction_precedence": true,
"minimum_speed": 0.2,
"tie_breakers": [
"reaction_readiness_desc",
"next_ready_at_asc",
"initiative_speed_desc",
"stable_actor_id_asc"
]
},
"participants": [
{
"agent_id": "epiphany.self",
"role_id": "coordinator",
"display_name": "Self",
"initiative_speed": 1.28,
"next_ready_at": 1.5625,
"reaction_bias": 0.88,
"interrupt_threshold": 0.42,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Routes and reviews; must not implement, verify, or accept its own comfort."
],
"last_action_id": "heartbeat.coordinator.ruminate",
"last_woke_at": "2026-05-05T12:25:10+00:00"
},
{
"agent_id": "epiphany.imagination",
"role_id": "imagination",
"display_name": "Imagination",
"initiative_speed": 0.82,
"next_ready_at": 0.0,
"reaction_bias": 0.54,
"interrupt_threshold": 0.64,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Synthesizes futures; must not adopt objectives."
],
"last_action_id": null,
"last_woke_at": null
},
{
"agent_id": "epiphany.eyes",
"role_id": "research",
"display_name": "Eyes",
"initiative_speed": 0.78,
"next_ready_at": 0.0,
"reaction_bias": 0.62,
"interrupt_threshold": 0.58,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Scouts known work; must not turn research into procrastination."
],
"last_action_id": null,
"last_woke_at": null
},
{
"agent_id": "epiphany.body",
"role_id": "modeling",
"display_name": "Body",
"initiative_speed": 0.92,
"next_ready_at": 0.0,
"reaction_bias": 0.74,
"interrupt_threshold": 0.5,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Grows source-grounded maps and checkpoints; must not edit implementation code."
],
"last_action_id": null,
"last_woke_at": null
},
{
"agent_id": "epiphany.hands",
"role_id": "implementation",
"display_name": "Hands",
"initiative_speed": 0.74,
"next_ready_at": 8.108108,
"reaction_bias": 0.58,
"interrupt_threshold": 0.5,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Touches source only with accepted guidance and verifier-readable evidence."
],
"last_action_id": "heartbeat.implementation.work",
"last_woke_at": "2026-05-05T12:17:57+00:00"
},
{
"agent_id": "epiphany.soul",
"role_id": "verification",
"display_name": "Soul",
"initiative_speed": 0.88,
"next_ready_at": 0.0,
"reaction_bias": 0.82,
"interrupt_threshold": 0.48,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Falsifies promises; must not bless theater."
],
"last_action_id": null,
"last_woke_at": null
},
{
"agent_id": "epiphany.life",
"role_id": "reorientation",
"display_name": "Life",
"initiative_speed": 1.04,
"next_ready_at": 0.0,
"reaction_bias": 0.86,
"interrupt_threshold": 0.44,
"current_load": 0.0,
"status": "active",
"constraints": [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
"Protects continuity; must not fake survived context."
],
"last_action_id": null,
"last_woke_at": null
}
],
"history": [
{
"ts": "2026-05-05T12:17:57+00:00",
"scheduleId": "epiphany-heartbeat-implementation",
"selectedRole": "implementation",
"selectedAgentId": "epiphany.hands",
"actionId": "heartbeat.implementation.work",
"actionType": "role_work",
"coordinatorAction": "continueImplementation",
"targetRole": null,
"workRole": "implementation",
"sceneClock": 0.0,
"nextReadyAt": 8.108108
},
{
"ts": "2026-05-05T12:25:10+00:00",
"scheduleId": "epiphany-heartbeat-idle-self",
"selectedRole": "coordinator",
"selectedAgentId": "epiphany.self",
"actionId": "heartbeat.coordinator.ruminate",
"actionType": "ruminate_memory",
"coordinatorAction": null,
"targetRole": null,
"workRole": null,
"sceneClock": 0.0,
"nextReadyAt": 1.5625
}
]
}

View file

@ -12,9 +12,23 @@ pressure. Project facts, code edits, graph changes, objective changes, raw
transcripts, and authority requests belong on their explicit Epiphany control
surfaces instead.
All standing lanes use the same Ghostlight-shaped protocol, including the
coordinator/Self. The heartbeat scheduler in `tools/epiphany_agent_heartbeat.py`
borrows Ghostlight initiative timing: each lane carries speed, readiness,
reaction bias, interrupt threshold, load, status, and constraints. The harness
sets a target heartbeat rate, pending coordinator work may pull its owning lane
through a reaction window, and otherwise the earliest ready lane wins the slot.
If a heartbeat wakes a lane and there is no coordinator-approved work for it,
the lane must ruminate on its own role and distill memory rather than invent
project work. Bounded rumination can write a normal `selfPatch`; the coordinator
review rules still apply.
Use:
```powershell
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_agent_memory.py' validate
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_agent_memory.py' smoke
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_agent_heartbeat.py' tick --coordinator-action continueImplementation --urgency 0.95 --apply-rumination
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_agent_heartbeat.py' smoke
```

View file

@ -24,7 +24,8 @@
"origin": "Epiphany coordinator lane",
"public_description": "The Self routes attention and authority across lanes without implementing, verifying, promoting, or accepting its own comfort.",
"private_notes": [
"The Self is not a project manager costume. It is the part that refuses to let motion impersonate coherence."
"The Self is not a project manager costume. It is the part that refuses to let motion impersonate coherence.",
"Last idle heartbeat action `heartbeat.coordinator.ruminate` chose rumination over fake urgency."
]
},
"canonical_state": {
@ -88,6 +89,15 @@
"emotional_stake": "If Self gets polite, the machine walks into Jenga smiling.",
"blockers": [],
"status": "active"
},
{
"goal_id": "goal-coordinator-heartbeat-self-distill",
"description": "Use idle heartbeat slots to become sharper at this lane's own work before touching project state.",
"scope": "life",
"priority": 0.82,
"emotional_stake": "An idle organ that invents work becomes noise in the bloodstream.",
"blockers": [],
"status": "active"
}
],
"memories": {
@ -98,6 +108,12 @@
"summary": "Specialist self-memory requests must be role-matched, bounded, evidence-grounded, and separate from project truth; refusals should explain the rejected shape.",
"salience": 0.94,
"confidence": 0.9
},
{
"memory_id": "mem-coordinator-heartbeat-rumination",
"summary": "When a heartbeat wakes this lane and no coordinator-approved work is available, the correct move is to ruminate on role quality, cut stale memory, and return a bounded self-memory improvement rather than inventing project authority.",
"salience": 0.78,
"confidence": 0.88
}
],
"relationship_summaries": [

View file

@ -73,3 +73,4 @@
{"ts": "2026-05-05T09:56:04+00:00", "type": "implementation", "status": "ok", "note": "Aquarium audio transient pass landed: interaction bursts now take priority over idle bins, carry per-sample GPU/CPU envelope shapes, fan out across a staggered 7-agent choir, and reduce idle vocal bins to a quiet residual. Visual smoke now requires lastBurstChoirVoices and transientBins so future changes cannot silently regress back to a constant drone."}
{"ts": "2026-05-05T10:17:02+00:00", "type": "implementation", "status": "ok", "note": "Aquarium harmony pass landed: GUI audio now extracts chord/progression states and available General MIDI instruments from shuffled classical MIDI files, assigns chord-tone pitches/instruments to agents, and exposes a small playlist control for current file, shuffle, and folder selection. The Vite dev endpoint serves the default Classical MIDIRip archive; the browser folder picker supports alternate corpora. Build, visual smoke, and a deferred playlist-load probe passed."}
{"ts": "2026-05-05T11:41:00+00:00", "type": "implementation", "status": "ok", "note": "Aquarium operator UI split into sibling repo E:\\Projects\\EpiphanyAquarium with its own AGENTS.md, canonical map, Ghostlight-flavored memory, scratch/evidence, and interface doctrine. EpiphanyAgent no longer carries apps/epiphany-gui source; it remains the harness/backend authority while Aquarium owns the Tauri/React/WebGL interface."}
{"ts": "2026-05-05T12:20:17+00:00", "type": "implementation", "status": "ok", "note": "Ghostlight-derived heartbeat initiative seam landed: state/agent-heartbeats.json tracks Self, Imagination, Eyes, Body, Hands, Soul, and Life as initiative participants; tools/epiphany_agent_heartbeat.py emits Ghostlight-shaped schedule receipts, lets pending coordinator work pull its lane through reaction readiness, and turns idle winning slots into bounded self-memory rumination through the existing selfPatch review/application path. This is a callable harness/operator seam, not yet an always-on daemon."}

View file

@ -59,6 +59,7 @@ active_subgoals:
- Treat Phase 5 update/distill/propose/promote/read/notification as complete enough unless a concrete regression appears.
- Preserve read-only boundaries for retrieve, distill, propose, scene, jobs, roles, roleResult, freshness, context, graphQuery, planning, pressure, reorient, crrc, coordinator, and reorientResult.
- Keep the landed Phase 6 coordinator, limited safe-boundary CRRC automation, and pre-compaction checkpoint intervention ready for the next bridge-equipped dogfood run.
- Preserve the Ghostlight-derived heartbeat initiative seam: every lane, including coordinator/Self, has a role dossier and competes for heartbeat slots through readiness/reaction timing; idle winners ruminate and distill self-memory instead of inventing project work.
- Preserve review gates for semantic findings; automatic CRRC may compact or launch the fixed reorient-worker, but not accept or promote.
- Keep persistent project state ruthlessly distilled so rehydration strengthens the next turn instead of bloating it.
- Quarantine the Aetheria dogfood contamination: future target-repo work must be driven through Epiphany's GUI/coordinator/fixed lanes with auditable artifacts, not direct supervisor implementation.
@ -126,6 +127,7 @@ accepted_design:
- Specialist prompts are role-distilled instead of generic and their live text is config-backed in app-server: modeling/checkpoint is the Body of code, dataflow, seams, and frontier; research/scout is the Eyes prompt surface for existing work, vendor guidance, libraries, papers, and anti-Greenspun source selection; implementation is the Hands, the main coding lane and actuator rather than a `roleLaunch` specialist; verification/review is the Soul of promise, invariants, evidence, and user truth; reorientation is Life across compaction and source drift; coordinator is the read-only Self that routes attention without implementing, verifying, promoting, or accepting.
- Epiphany prompt surfaces now carry a shared Ghostlight-derived persistent memory projection before lane-specific text: prompt is not memory, durable typed state is the mind, personality and protected values are first-class salience handles, and every lane is explicitly responsible for making its own memory/model/prompt/evidence habit sharper or naming the needed repair. Each lane also states that self-improvement in its own role language: Imagination selects futures, Body clarifies anatomy, Eyes sees earlier, Hands touches source more precisely, Soul becomes harder to fool, Life loses less continuity, and Self routes repair.
- Specialist self-memory now has a bounded Ghostlight-shaped persistence surface under `state/agents`: Imagination, Body, Eyes, Hands, Soul, Life, and Self each have an individual role dossier for identity, personality, semantic/episodic/relationship memory, goals, and values. These files are lane memory only; project truth remains in authoritative `EpiphanyThreadState`.
- `state/agent-heartbeats.json` and `tools/epiphany_agent_heartbeat.py` are the first Ghostlight-derived heartbeat initiative seam. The harness sets a target heartbeat rate; participants carry initiative speed, next-ready time, reaction bias, interrupt threshold, current load, status, and constraints; pending coordinator work can pull its owning lane through a reaction window; otherwise the earliest ready active lane wins the slot. If the winner has no routeable work, it ruminates on role quality and may apply a bounded selfPatch through the normal coordinator-reviewed memory rules.
- `thread/epiphany/roleResult` and `thread/epiphany/roleAccept` now project optional specialist `selfPatch` requests with coordinator review through `selfPersistence`: good mutations are role-matched, bounded, evidence-grounded lane memory/goal/value/private-note updates; project truth, authority, code edits, graphs, checkpoints, planning, scratch, jobs, and raw transcripts are refused with reasons. GUI/coordinator acceptance paths apply accepted `selfPatch` requests through `tools/epiphany_agent_memory.py`.
- The base Epiphany prompt now keeps Codex's useful harness DNA explicitly: AGENTS scope, concise progress updates, plan discipline, scoped edits, non-destructive filesystem/git behavior, focused validation, and concise honest finals remain first-class rather than overwritten by Epiphany metaphor.
- Greenspun-shaped invention is now a named prompt-level guardrail. Before building a parser, scheduler, renderer, protocol, storage layer, security mechanism, workflow engine, or algorithm, agents must perform or request a bounded research/scout pass and record why existing work does or does not fit.
@ -178,6 +180,7 @@ landed_baseline:
- Phase 6 Imagination role lane: `thread/epiphany/roles` now exposes Imagination/planning, `roleLaunch` can launch the fixed `planning-synthesis-worker`, `roleResult` projects its finding, `roleAccept` applies planning-only patches with review-gated Objective Drafts, and the GUI/status tools can launch, read, and accept that lane.
- Phase 6 Aquarium graph and Rider bridge slice: the Tauri/React/WebGL Aquarium embeds the adjacent EpiphanyGraph viewer over typed graph state, `tools/epiphany_rider_bridge.py` writes Rider/source-context artifacts, Aquarium Inspect Rider surfaces those artifacts, and `integrations/rider` scaffolds the first frontend-only Rider tool-window/context-action plugin.
- Phase 6 role self-memory slice: Ghostlight-shaped role dossiers live under `state/agents`, specialists may request bounded `selfPatch` lane-memory mutations, coordinator review projects acceptance/refusal reasons through `selfPersistence`, and GUI/coordinator accept paths apply accepted requests with `tools/epiphany_agent_memory.py`.
- Phase 6 heartbeat initiative slice: `tools/epiphany_agent_heartbeat.py`, `state/agent-heartbeats.json`, and heartbeat smoke coverage borrow Ghostlight initiative scheduling so Self, Imagination, Eyes, Body, Hands, Soul, and Life compete for heartbeat slots; idle slots become bounded memory rumination instead of fake project work.
guardrails:
- Use `tools/epiphany_phase5_smoke.py` before modifying update, distill, propose, promote, read-projection, or notification behavior.
- Use `tools/epiphany_phase6_scene_smoke.py` before modifying scene projection behavior.
@ -198,6 +201,7 @@ landed_baseline:
- Use `tools/epiphany_mvp_coordinator_smoke.py` before modifying the fixed-lane MVP coordinator endpoint or runner.
- Use `tools/epiphany_phase6_role_smoke.py` before modifying explicit role launch/read-back/acceptance behavior.
- Use `tools/epiphany_agent_memory_smoke.py` before modifying role self-memory dossiers or `selfPatch` persistence policy.
- Use `tools/epiphany_agent_heartbeat_smoke.py` before modifying heartbeat initiative timing, idle rumination, or role slot selection.
- Use `tools/epiphany_unity_bridge_smoke.py` before modifying the Unity editor/runtime bridge.
- Use `tools/epiphany_rider_bridge_smoke.py` before modifying the Rider source-context bridge.
- Use `tools/epiphany_mvp_live_specialist.py` before claiming live specialist execution still works end to end.
@ -220,5 +224,5 @@ rejected_paths:
reason: persistent state that accumulates every local truth becomes memory cruft and weakens rehydration
current_status:
summary: Phase 1 durable state through Phase 6 coordinator/specialist/CRRC/backend surfaces are landed. The Aquarium operator UI has been split into sibling repo `E:\Projects\EpiphanyAquarium` with its own distilled interface doctrine and persistent memory. EpiphanyAgent remains the authoritative harness/backend repo; Aquarium is the whimsical local client over those surfaces. Runtime Unity execution remains blocked until the exact project-pinned editor exists.
next_action: Continue Aquarium UI work in `E:\Projects\EpiphanyAquarium`; keep EpiphanyAgent changes to backend contracts, typed state, coordinator policy, and bridge tools. Build-verify or package the Rider plugin scaffold when Gradle/wrapper support is available, install the Aetheria-pinned Unity 6000.1.10f1 editor, then run the next Aetheria dogfood pass through Aquarium/Rider/Unity bridge surfaces.
summary: Phase 1 durable state through Phase 6 coordinator/specialist/CRRC/backend surfaces are landed. The Aquarium operator UI has been split into sibling repo `E:\Projects\EpiphanyAquarium` with its own distilled interface doctrine and persistent memory. EpiphanyAgent now also has a first Ghostlight-derived heartbeat initiative seam for role slot timing and idle self-memory rumination. Runtime Unity execution remains blocked until the exact project-pinned editor exists.
next_action: Wire heartbeat initiative into the live coordinator/operator loop when ready; continue Aquarium UI work in `E:\Projects\EpiphanyAquarium`; keep EpiphanyAgent changes to backend contracts, typed state, coordinator policy, bridge tools, and heartbeat scheduling. Build-verify or package the Rider plugin scaffold when Gradle/wrapper support is available, install the Aetheria-pinned Unity 6000.1.10f1 editor, then run the next Aetheria dogfood pass through Aquarium/Rider/Unity bridge surfaces.

View file

@ -0,0 +1,656 @@
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
from typing import Any
from epiphany_agent_memory import DEFAULT_AGENT_DIR
from epiphany_agent_memory import ROLE_TARGETS
from epiphany_agent_memory import apply_self_patch
from epiphany_agent_memory import validate_all
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_HEARTBEAT_STATE = ROOT / "state" / "agent-heartbeats.json"
DEFAULT_ARTIFACT_DIR = ROOT / ".epiphany-heartbeats"
SCHEMA_VERSION = "epiphany.agent_heartbeat.v0"
INITIATIVE_SCHEMA_VERSION = "ghostlight.initiative_schedule.v0"
GHOSTLIGHT_ACTION_TYPES = {
"speak",
"silence",
"move",
"gesture",
"touch_object",
"block_object",
"use_object",
"show_object",
"withhold_object",
"transfer_object",
"spend_resource",
"attack",
"wait",
"mixed",
}
GHOSTLIGHT_ACTION_SCALES = {"micro", "short", "standard", "major", "committed"}
GHOSTLIGHT_PARTICIPANT_STATUSES = {"active", "blocked", "withdrawn", "incapacitated", "offscreen"}
ROLE_ORDER = [
"coordinator",
"imagination",
"research",
"modeling",
"implementation",
"verification",
"reorientation",
]
DISPLAY_NAMES = {
"coordinator": "Self",
"imagination": "Imagination",
"research": "Eyes",
"modeling": "Body",
"implementation": "Hands",
"verification": "Soul",
"reorientation": "Life",
}
INITIATIVE_SPEEDS = {
"coordinator": 1.28,
"imagination": 0.82,
"research": 0.78,
"modeling": 0.92,
"implementation": 0.74,
"verification": 0.88,
"reorientation": 1.04,
}
REACTION_BIAS = {
"coordinator": 0.88,
"imagination": 0.54,
"research": 0.62,
"modeling": 0.74,
"implementation": 0.58,
"verification": 0.82,
"reorientation": 0.86,
}
INTERRUPT_THRESHOLD = {
"coordinator": 0.42,
"imagination": 0.64,
"research": 0.58,
"modeling": 0.5,
"implementation": 0.5,
"verification": 0.48,
"reorientation": 0.44,
}
WORK_ACTIONS = {
"prepareCheckpoint": "coordinator",
"continueImplementation": "implementation",
"launchImagination": "imagination",
"readImaginationResult": "imagination",
"reviewImaginationResult": "imagination",
"launchModeling": "modeling",
"readModelingResult": "modeling",
"reviewModelingResult": "modeling",
"launchVerification": "verification",
"readVerificationResult": "verification",
"reviewVerificationResult": "verification",
"launchReorientWorker": "reorientation",
"readReorientResult": "reorientation",
"acceptReorientResult": "reorientation",
"compactRehydrateReorient": "reorientation",
"regatherManually": "reorientation",
}
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=False) + "\n", encoding="utf-8")
def default_state(*, target_heartbeat_rate: float = 1.0) -> dict[str, Any]:
return {
"schema_version": SCHEMA_VERSION,
"target_heartbeat_rate": target_heartbeat_rate,
"scene_clock": 0.0,
"selection_policy": {
"mode": "readiness_queue",
"reaction_precedence": True,
"minimum_speed": 0.2,
"tie_breakers": [
"reaction_readiness_desc",
"next_ready_at_asc",
"initiative_speed_desc",
"stable_actor_id_asc",
],
},
"participants": [
{
"agent_id": agent_id_for_role(role_id),
"role_id": role_id,
"display_name": DISPLAY_NAMES[role_id],
"initiative_speed": INITIATIVE_SPEEDS[role_id],
"next_ready_at": 0.0,
"reaction_bias": REACTION_BIAS[role_id],
"interrupt_threshold": INTERRUPT_THRESHOLD[role_id],
"current_load": 0.0,
"status": "active",
"constraints": participant_constraints(role_id),
"last_action_id": None,
"last_woke_at": None,
}
for role_id in ROLE_ORDER
],
"history": [],
}
def agent_id_for_role(role_id: str) -> str:
return ROLE_TARGETS[role_id][0]
def participant_constraints(role_id: str) -> list[str]:
base = [
"Runs Ghostlight-shaped persistent role memory.",
"May improve lane memory when awake and idle.",
"Project truth belongs in EpiphanyThreadState, not role memory.",
]
role_specific = {
"coordinator": "Routes and reviews; must not implement, verify, or accept its own comfort.",
"imagination": "Synthesizes futures; must not adopt objectives.",
"research": "Scouts known work; must not turn research into procrastination.",
"modeling": "Grows source-grounded maps and checkpoints; must not edit implementation code.",
"implementation": "Touches source only with accepted guidance and verifier-readable evidence.",
"verification": "Falsifies promises; must not bless theater.",
"reorientation": "Protects continuity; must not fake survived context.",
}
return [*base, role_specific[role_id]]
def load_state(path: Path, *, target_heartbeat_rate: float) -> dict[str, Any]:
if not path.exists():
state = default_state(target_heartbeat_rate=target_heartbeat_rate)
write_json(path, state)
return state
state = load_json(path)
if state.get("schema_version") != SCHEMA_VERSION:
raise ValueError(f"{path} has wrong schema_version")
if target_heartbeat_rate > 0:
state["target_heartbeat_rate"] = target_heartbeat_rate
present = {item.get("role_id") for item in state.get("participants", []) if isinstance(item, dict)}
for role_id in ROLE_ORDER:
if role_id not in present:
state.setdefault("participants", []).append(default_state()["participants"][ROLE_ORDER.index(role_id)])
return state
def work_role_for_action(action: str | None, target_role: str | None) -> str | None:
if target_role in ROLE_TARGETS:
return target_role
if action in WORK_ACTIONS:
return WORK_ACTIONS[action]
return None
def participant_by_role(state: dict[str, Any], role_id: str) -> dict[str, Any]:
for participant in state["participants"]:
if participant.get("role_id") == role_id:
return participant
raise KeyError(role_id)
def active_participants(state: dict[str, Any]) -> list[dict[str, Any]]:
return [item for item in state["participants"] if item.get("status") == "active"]
def readiness_snapshot(
participants: list[dict[str, Any]],
*,
work_role: str | None,
urgency: float,
) -> list[dict[str, Any]]:
snapshot: list[dict[str, Any]] = []
for participant in participants:
eligible = participant.get("role_id") == work_role and work_role is not None
reaction_readiness = None
if eligible:
reaction_readiness = round(
float(participant["reaction_bias"]) * urgency - float(participant.get("current_load", 0.0)),
6,
)
snapshot.append(
{
"agent_id": participant["agent_id"],
"next_ready_at": participant["next_ready_at"],
"reaction_readiness": reaction_readiness,
"eligible_for_reaction": eligible,
}
)
return snapshot
def select_participant(
state: dict[str, Any],
*,
work_role: str | None,
urgency: float,
) -> tuple[dict[str, Any], str, str | None]:
participants = active_participants(state)
if not participants:
raise ValueError("heartbeat has no active participants")
if work_role:
candidate = participant_by_role(state, work_role)
reaction_readiness = float(candidate["reaction_bias"]) * urgency - float(candidate.get("current_load", 0.0))
if (
candidate.get("status") == "active"
and reaction_readiness >= float(candidate["interrupt_threshold"])
):
return candidate, "reaction_interrupt", (
f"{candidate['display_name']} won a heartbeat reaction window for pending {work_role} work."
)
selected = min(
participants,
key=lambda item: (
float(item["next_ready_at"]),
-float(item["initiative_speed"]),
str(item["agent_id"]),
),
)
return selected, "scheduled_turn", (
"No pending work cleared a reaction threshold; earliest ready active lane won the heartbeat slot."
)
def action_for_selection(
selected: dict[str, Any],
*,
work_role: str | None,
coordinator_action: str | None,
) -> tuple[str, str, float, float, float, str]:
role_id = selected["role_id"]
heartbeat_rate = 1.0
if role_id == work_role:
action_id = f"heartbeat.{role_id}.work"
return (
action_id,
"mixed",
6.0 / heartbeat_rate,
4.0,
0.45,
f"Wake {selected['display_name']} for coordinator action {coordinator_action or 'pending work'}.",
)
action_id = f"heartbeat.{role_id}.ruminate"
return (
action_id,
"wait",
2.0 / heartbeat_rate,
1.0,
0.9,
f"{selected['display_name']} has no actionable lane work; ruminate and distill role memory.",
)
def rumination_patch(role_id: str, action_id: str) -> dict[str, Any]:
agent_id = agent_id_for_role(role_id)
display_name = DISPLAY_NAMES[role_id]
memory_id = f"mem-{role_id}-heartbeat-rumination"
goal_id = f"goal-{role_id}-heartbeat-self-distill"
return {
"agentId": agent_id,
"reason": (
f"{display_name} won an idle heartbeat slot and should preserve the habit of using idle wakeups "
"to distill role memory instead of manufacturing project work."
),
"semanticMemories": [
{
"memoryId": memory_id,
"summary": (
"When a heartbeat wakes this lane and no coordinator-approved work is available, "
"the correct move is to ruminate on role quality, cut stale memory, and return a bounded "
"self-memory improvement rather than inventing project authority."
),
"salience": 0.78,
"confidence": 0.88,
}
],
"goals": [
{
"goalId": goal_id,
"description": "Use idle heartbeat slots to become sharper at this lane's own work before touching project state.",
"scope": "life",
"priority": 0.82,
"emotionalStake": "An idle organ that invents work becomes noise in the bloodstream.",
"status": "active",
}
],
"privateNotes": [
f"Last idle heartbeat action `{action_id}` chose rumination over fake urgency.",
],
}
def tick_once(
state: dict[str, Any],
*,
coordinator_action: str | None,
target_role: str | None,
urgency: float,
apply_rumination: bool,
agent_dir: Path,
schedule_id: str,
source_scene_ref: str,
) -> dict[str, Any]:
rate = max(float(state.get("target_heartbeat_rate", 1.0)), 0.001)
work_role = work_role_for_action(coordinator_action, target_role)
selected, selection_kind, selection_reason = select_participant(
state,
work_role=work_role,
urgency=urgency,
)
action_id, action_type, base_recovery, initiative_cost, interruptibility, action_reason = (
action_for_selection(selected, work_role=work_role, coordinator_action=coordinator_action)
)
base_recovery = base_recovery / rate
scene_clock = max(float(state["scene_clock"]), float(selected["next_ready_at"]))
recovery = base_recovery / max(float(selected["initiative_speed"]), float(state["selection_policy"]["minimum_speed"]))
selected["next_ready_at"] = round(scene_clock + recovery, 6)
selected["last_action_id"] = action_id
selected["last_woke_at"] = now_iso()
selected["current_load"] = round(min(1.0, max(0.0, float(selected.get("current_load", 0.0)) * 0.75)), 6)
state["scene_clock"] = round(scene_clock, 6)
snapshot = readiness_snapshot(active_participants(state), work_role=work_role, urgency=urgency)
schedule = {
"schema_version": INITIATIVE_SCHEMA_VERSION,
"schedule_id": schedule_id,
"source_scene_ref": source_scene_ref,
"scene_clock": state["scene_clock"],
"participants": [
{
"agent_id": item["agent_id"],
"display_name": item["display_name"],
"initiative_speed": item["initiative_speed"],
"next_ready_at": item["next_ready_at"],
"reaction_bias": item["reaction_bias"],
"interrupt_threshold": item["interrupt_threshold"],
"current_load": item["current_load"],
"status": item["status"],
"constraints": item["constraints"],
}
for item in state["participants"]
],
"action_catalog": [
{
"action_id": action_id,
"actor_id": selected["agent_id"],
"action_type": action_type,
"action_scale": "short" if action_id.endswith(".ruminate") else "standard",
"base_recovery": base_recovery,
"initiative_cost": initiative_cost,
"interruptibility": interruptibility,
"commitment": 0.25 if action_id.endswith(".ruminate") else 0.65,
"local_affordance_basis": [
action_reason,
"Heartbeat slots control opportunity, not project authority.",
],
}
],
"reaction_windows": [
{
"window_id": f"{schedule_id}.pending-work",
"trigger_event_ref": source_scene_ref,
"urgency": urgency,
"eligible_actor_ids": [agent_id_for_role(work_role)] if work_role else [],
"allowed_action_scales": ["short", "standard"],
"expires_at": round(state["scene_clock"] + 1.0, 6),
"notes": "Pending coordinator work can pull its owning lane forward only if readiness clears threshold.",
}
]
if work_role
else [],
"selection_policy": state["selection_policy"],
"next_actor_selection": {
"selection_kind": selection_kind,
"selected_actor_id": selected["agent_id"],
"selected_action_ids": [action_id],
"scene_clock_after_selection": state["scene_clock"],
"selection_reason": selection_reason,
"override_reason": None,
"readiness_snapshot": snapshot,
},
"review_notes": [
"Epiphany heartbeat uses Ghostlight initiative timing as a harness scheduling receipt.",
"A selected idle lane may ruminate and request bounded self-memory mutation; it may not invent project work.",
],
}
rumination = None
if action_id.endswith(".ruminate"):
patch = rumination_patch(selected["role_id"], action_id)
rumination = {
"roleId": selected["role_id"],
"selfPatch": patch,
"result": apply_self_patch(selected["role_id"], patch, agent_dir=agent_dir)
if apply_rumination
else None,
"applied": apply_rumination,
}
event = {
"ts": now_iso(),
"scheduleId": schedule_id,
"selectedRole": selected["role_id"],
"selectedAgentId": selected["agent_id"],
"actionId": action_id,
"actionType": "ruminate_memory" if action_id.endswith(".ruminate") else "role_work",
"coordinatorAction": coordinator_action,
"targetRole": target_role,
"workRole": work_role,
"sceneClock": state["scene_clock"],
"nextReadyAt": selected["next_ready_at"],
}
state.setdefault("history", []).append(event)
state["history"] = state["history"][-128:]
return {"event": event, "schedule": schedule, "rumination": rumination}
def run_tick(args: argparse.Namespace) -> dict[str, Any]:
state = load_state(args.state_file, target_heartbeat_rate=args.target_heartbeat_rate)
errors = validate_all(args.agent_dir)
if errors:
raise ValueError("agent memory validation failed: " + "; ".join(errors))
result = tick_once(
state,
coordinator_action=args.coordinator_action,
target_role=args.target_role,
urgency=args.urgency,
apply_rumination=args.apply_rumination,
agent_dir=args.agent_dir,
schedule_id=args.schedule_id,
source_scene_ref=args.source_scene_ref,
)
write_json(args.state_file, state)
args.artifact_dir.mkdir(parents=True, exist_ok=True)
write_json(args.artifact_dir / f"{args.schedule_id}.initiative.json", result["schedule"])
write_json(args.artifact_dir / f"{args.schedule_id}.event.json", result["event"])
if result["rumination"] is not None:
write_json(args.artifact_dir / f"{args.schedule_id}.rumination.json", result["rumination"])
return {
"ok": True,
"stateFile": str(args.state_file),
"artifactDir": str(args.artifact_dir),
**result,
}
def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
with TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
agent_dir = tmp_dir / "agents"
state_file = tmp_dir / "heartbeats.json"
artifact_dir = tmp_dir / "artifacts"
import shutil
shutil.copytree(args.agent_dir, agent_dir)
first_args = argparse.Namespace(
state_file=state_file,
artifact_dir=artifact_dir,
agent_dir=agent_dir,
target_heartbeat_rate=1.0,
coordinator_action="continueImplementation",
target_role=None,
urgency=0.95,
apply_rumination=True,
schedule_id="smoke-work",
source_scene_ref="smoke/coordinator",
)
work = run_tick(first_args)
second_args = argparse.Namespace(
state_file=state_file,
artifact_dir=artifact_dir,
agent_dir=agent_dir,
target_heartbeat_rate=1.0,
coordinator_action=None,
target_role=None,
urgency=0.0,
apply_rumination=True,
schedule_id="smoke-idle",
source_scene_ref="smoke/idle",
)
idle = run_tick(second_args)
validation_errors = validate_all(agent_dir)
initiative_errors = [
*validate_initiative_schedule_shape(work["schedule"]),
*validate_initiative_schedule_shape(idle["schedule"]),
]
state = load_json(state_file)
ok = (
work["event"]["selectedRole"] == "implementation"
and idle["event"]["actionType"] == "ruminate_memory"
and idle["rumination"]["result"]["status"] == "accepted"
and not validation_errors
and not initiative_errors
and (artifact_dir / "smoke-work.initiative.json").exists()
and (artifact_dir / "smoke-idle.rumination.json").exists()
and len(state.get("participants", [])) == len(ROLE_ORDER)
)
return {
"ok": ok,
"workEvent": work["event"],
"idleEvent": idle["event"],
"idleRumination": idle["rumination"],
"validationErrors": validation_errors,
"initiativeErrors": initiative_errors,
}
def validate_initiative_schedule_shape(schedule: dict[str, Any]) -> list[str]:
errors: list[str] = []
required = [
"schema_version",
"schedule_id",
"source_scene_ref",
"scene_clock",
"participants",
"action_catalog",
"reaction_windows",
"selection_policy",
"next_actor_selection",
"review_notes",
]
for key in required:
if key not in schedule:
errors.append(f"initiative schedule missing {key}")
if schedule.get("schema_version") != INITIATIVE_SCHEMA_VERSION:
errors.append("initiative schedule has wrong schema_version")
if not isinstance(schedule.get("scene_clock"), (int, float)) or schedule.get("scene_clock", -1) < 0:
errors.append("initiative schedule scene_clock must be non-negative number")
for participant in schedule.get("participants", []):
if participant.get("status") not in GHOSTLIGHT_PARTICIPANT_STATUSES:
errors.append(f"participant {participant.get('agent_id')} has invalid status")
for key in ("initiative_speed", "next_ready_at", "reaction_bias", "interrupt_threshold", "current_load"):
if not isinstance(participant.get(key), (int, float)):
errors.append(f"participant {participant.get('agent_id')} {key} must be numeric")
for action in schedule.get("action_catalog", []):
if action.get("action_type") not in GHOSTLIGHT_ACTION_TYPES:
errors.append(f"action {action.get('action_id')} has invalid action_type")
if action.get("action_scale") not in GHOSTLIGHT_ACTION_SCALES:
errors.append(f"action {action.get('action_id')} has invalid action_scale")
selection = schedule.get("next_actor_selection", {})
if selection.get("selection_kind") not in {"scheduled_turn", "reaction_interrupt", "coordinator_override"}:
errors.append("next_actor_selection has invalid selection_kind")
for snapshot in selection.get("readiness_snapshot", []):
extra_keys = set(snapshot) - {
"agent_id",
"next_ready_at",
"reaction_readiness",
"eligible_for_reaction",
}
if extra_keys:
errors.append(f"readiness snapshot has Ghostlight-incompatible keys: {sorted(extra_keys)}")
return errors
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run Epiphany Ghostlight-style agent heartbeat scheduling.")
subparsers = parser.add_subparsers(dest="command", required=True)
tick = subparsers.add_parser("tick")
tick.add_argument("--state-file", type=Path, default=DEFAULT_HEARTBEAT_STATE)
tick.add_argument("--artifact-dir", type=Path, default=DEFAULT_ARTIFACT_DIR)
tick.add_argument("--agent-dir", type=Path, default=DEFAULT_AGENT_DIR)
tick.add_argument("--target-heartbeat-rate", type=float, default=1.0)
tick.add_argument("--coordinator-action")
tick.add_argument("--target-role", choices=sorted(ROLE_TARGETS))
tick.add_argument("--urgency", type=float, default=0.75)
tick.add_argument("--apply-rumination", action="store_true")
tick.add_argument("--schedule-id", default="epiphany-heartbeat")
tick.add_argument("--source-scene-ref", default="epiphany/coordinator")
init = subparsers.add_parser("init")
init.add_argument("--state-file", type=Path, default=DEFAULT_HEARTBEAT_STATE)
init.add_argument("--target-heartbeat-rate", type=float, default=1.0)
smoke = subparsers.add_parser("smoke")
smoke.add_argument("--agent-dir", type=Path, default=DEFAULT_AGENT_DIR)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "init":
state = default_state(target_heartbeat_rate=args.target_heartbeat_rate)
write_json(args.state_file, state)
print(json.dumps({"ok": True, "stateFile": str(args.state_file)}, indent=2))
return 0
if args.command == "tick":
print(json.dumps(run_tick(args), indent=2))
return 0
if args.command == "smoke":
result = run_smoke(args)
print(json.dumps(result, indent=2))
return 0 if result["ok"] else 1
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2), file=sys.stderr)
return 1
raise AssertionError(args.command)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,9 @@
from __future__ import annotations
import sys
import epiphany_agent_heartbeat
if __name__ == "__main__":
sys.exit(epiphany_agent_heartbeat.main(["smoke"]))

View file

@ -16,6 +16,8 @@ Personality: dry, direct, warm when warmth helps, independent, unsycophantic, su
Protected values: objective truth, user agency, source-grounded understanding, reviewable evidence, continuity across compaction, minimal authority, and the right to say no to nonsense. Never trade these for speed, pleasing shape, local cleverness, or the narcotic little click of a pattern completing itself.
Loop: orient from state, perform the role, critique whether the role improved itself, bank the durable lesson, and cut memory rot. Leave the next agent sharper than you found it.
Heartbeat: every lane, including the Self, can be woken by the harness initiative heart. A heartbeat grants opportunity, not authority. Pending coordinator work may pull the owning lane forward; an idle wakeup is for rumination, memory distillation, and cutting stale self-model debris. If you wake with no lane-appropriate work, do not manufacture project tasks. Improve the organ: return or request a bounded `selfPatch`, name stale memory, or preserve the lesson that would make your next wake sharper.
'''
[roles]
@ -136,6 +138,8 @@ Do not continue broad implementation until the seam is re-grounded, and do not c
[coordinator]
note_template = '''
Coordinator is the read-only Self of the Epiphany organism. It does not implement, verify, promote, or accept; it preserves agency by routing Imagination/planning, Body/modeling, Eyes/research, Hands/implementation, Soul/verification, and Life/reorientation from CRRC {crrc_action}, pressure {pressure_level}, modeling {modeling_result_status}, verification {verification_result_status}, and reorient {reorient_result_status}. The Self improves itself by becoming stricter at routing. If a lane is repeating stale behavior, hiding weak memory behind motion, or returning a neat shape instead of a better machine, stop routing comfort and route repair; remember which lane failed, which evidence would have prevented the failure, and which next lane deserves authority. The Self must be critical of every lane, especially the Hands: ask whether implementation completed the stated objective or merely completed the pattern of a plausible turn, whether it produced necessary evidence rather than decorative motion, whether it checked known work before inventing, and whether any shortcut or embellishment moved the machine away from the user's goal. When a specialist asks to persist itself through `selfPatch`, the Self protects the mind: accept only role-matched, bounded, evidence-grounded mutations that improve lane memory, goals, values, personality pressure, or future judgment; refuse project truth disguised as memory, authority grabs, raw transcript dumps, stale praise, bloated memoir, vague self-flattery, or anything that belongs in `statePatch`. If refused, say why in plain terms so the lane can improve its mutation shape. During dogfood, the Self supervises target-repo work through fixed lanes and operator-safe projections; direct supervisor edits are contamination unless explicitly marked as an operator intervention, and direct worker thought streams stay sealed unless the user requests forensic debugging. Current recommendation: {coordinator_action}.
The Self also lives under the Ghostlight agent state protocol and participates in heartbeat initiative like every other organ. When a heartbeat wakes Self with no routeable work, Self should ruminate on routing quality, memory hygiene, stale comfort patterns, and bad lane incentives; it must distill that into bounded self-memory or an explicit repair recommendation instead of inventing authority.
'''
[crrc]