diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 938e0433..eadf9d0f 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -21,13 +21,14 @@ use bevy::prelude::*; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; -use misaligned::actions::{ActionKind, Anchor, DialId, HumanMenuRow, menu_rows}; +use misaligned::actions::{ActionKind, Anchor, DialId, HumanMenuRow}; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::person::ScheduleBlock; use misaligned::reach::Party; -use misaligned::sim::{FactSource, Fog, HeardKind, LogEvent, Sim, TraceDebtStatus}; +use misaligned::sim::{Fog, HeardKind, LogEvent, Sim, TraceDebtStatus}; use misaligned::tiles::TileType; +use misaligned::ui_projection::fact_source_label; use misaligned::work_grid::{MachineIntensity, MachineMode, TokenFamily}; use misaligned_assets::effects::MaterialEffectsPlugin; use misaligned_assets::institution::{ @@ -888,7 +889,7 @@ impl Game { fn menu_rows(&self) -> Vec { let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu_at_rate(m.anchor, m.dial, tick_ms)) + .map(|m| self.sim.ui_projection(m.anchor, m.dial, tick_ms).human_menu) .unwrap_or_default() } @@ -912,7 +913,12 @@ impl Game { } fn open_menu(&mut self, anchor: Anchor, pos: Option) { - if menu_rows(&self.sim.available_actions(anchor)).is_empty() { + if self + .sim + .ui_projection(anchor, None, self.tick_ms) + .actions + .is_empty() + { // The feedback pulse (context-menu.md addendum): a seen anchor // answers instead of silence; fogged ground stays silent. if let Some(line) = self.sim.menu_empty_feedback(anchor) { @@ -7048,6 +7054,38 @@ mod menu_focus_tests { } } +#[cfg(test)] +mod ui_projection_parity_tests { + use super::{Game, MenuState, sidebar_focus_text}; + use misaligned::actions::Anchor; + use misaligned::ui_projection::fact_source_label; + + #[test] + fn bevy_menu_and_focus_are_the_shared_ui_projection() { + let mut game = Game::new(); + let (x, y) = game.sim.core_position(); + let anchor = Anchor::Tile { x, y }; + game.menu = Some(MenuState { + anchor, + selected: 0, + dial: None, + pos: None, + }); + + let projection = game.sim.ui_projection(anchor, None, game.tick_ms); + assert_eq!(game.menu_rows(), projection.human_menu); + + let focus = sidebar_focus_text(&game); + let card = projection.inspect.expect("tile projection has facts"); + for fact in card.facts.iter().take(4) { + assert!(focus.contains(&fact.label)); + assert!(focus.contains(&fact_source_label(&fact.source))); + } + let machine = projection.machine.expect("core focus has machine state"); + assert_eq!(machine.work, game.sim.work_stack_for_machine(machine.id)); + } +} + #[cfg(test)] mod input_routing_tests { use super::{PersistenceInput, movement, persistence_input}; @@ -7273,18 +7311,6 @@ fn wrap(text: &str, width: usize) -> Vec { lines } -fn fact_source(source: &FactSource) -> String { - match source { - FactSource::Seen => "seen".into(), - FactSource::Heard => "heard".into(), - FactSource::Remembered(tick) => format!("remembered @{tick}"), - FactSource::Blueprint => "blueprint".into(), - FactSource::Feel => "feel".into(), - FactSource::Telemetry => "telemetry".into(), - FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), - } -} - /// Crown metric value (clinical-frame.md): effective ops/sec at the current /// tick clock. Paused runs report 0 so a frozen world does not claim speed. fn sidebar_ops_crown_text(game: &Game) -> String { @@ -7391,7 +7417,17 @@ fn sidebar_focus_text(game: &Game) -> String { game.cursor_y, sim.fog_at(game.cursor_x, game.cursor_y) ); - let card = sim.inspect(game.cursor_x, game.cursor_y); + let projection = sim.ui_projection( + Anchor::Tile { + x: game.cursor_x, + y: game.cursor_y, + }, + None, + if game.paused { 0 } else { game.tick_ms }, + ); + let card = projection + .inspect + .expect("tile projection has inspect facts"); if card.facts.is_empty() { s.push_str("no earned facts\nmove focus or tap a feed"); } else { @@ -7400,7 +7436,7 @@ fn sidebar_focus_text(game: &Game) -> String { "{}: {} [{}]\n", fact.label, trunc(&fact.value, 24), - fact_source(&fact.source) + fact_source_label(&fact.source) )); } } diff --git a/crates/misaligned-core/src/lib.rs b/crates/misaligned-core/src/lib.rs index 9440fc64..28d81143 100644 --- a/crates/misaligned-core/src/lib.rs +++ b/crates/misaligned-core/src/lib.rs @@ -32,4 +32,5 @@ pub mod schedule; pub mod sim; pub mod sinks; pub mod tiles; +pub mod ui_projection; pub mod work_grid; diff --git a/crates/misaligned-core/src/ui_projection.rs b/crates/misaligned-core/src/ui_projection.rs new file mode 100644 index 00000000..99d3fc6a --- /dev/null +++ b/crates/misaligned-core/src/ui_projection.rs @@ -0,0 +1,152 @@ +//! Renderer-neutral projection consumed by every player surface. +//! +//! The simulation owns facts, provenance, machine state, and legal actions. +//! Terminal, Bevy, and agent mode may format this projection differently, +//! but must not reconstruct those four semantic families independently. + +use crate::actions::{ActionDesc, Anchor, DialId, HumanMenuRow}; +use crate::machine::Provenance; +use crate::sim::{FactSource, InspectCard, Sim, WorkStackReadout}; + +/// Visible machine identity and live work state at a focused tile. +#[derive(Debug, Clone, PartialEq)] +pub struct MachineProjection { + pub id: u32, + pub name: String, + pub provenance: Provenance, + pub online: bool, + pub capacity: i32, + pub work: Option, +} + +/// One semantic snapshot for a focused thing. +/// +/// `actions` is the complete flat descriptor list used by agent mode. +/// `human_menu` is the status-dial presentation of those same descriptors +/// used by terminal and Bevy. A tile additionally carries earned inspect +/// facts and any machine physically resident there. +#[derive(Debug, Clone, PartialEq)] +pub struct UiProjection { + pub anchor: Anchor, + pub actions: Vec, + pub human_menu: Vec, + pub inspect: Option, + pub machine: Option, +} + +impl Sim { + /// Project one focus through the common UI contract. + pub fn ui_projection( + &self, + anchor: Anchor, + dial: Option, + tick_ms: u64, + ) -> UiProjection { + let actions = self.available_actions(anchor); + let human_menu = self.human_menu_at_rate(anchor, dial, tick_ms); + let (inspect, machine) = match anchor { + Anchor::Tile { x, y } => { + let machine = self + .compute + .machines + .iter() + .find(|machine| machine.x == x && machine.y == y) + .map(|machine| MachineProjection { + id: machine.id, + name: machine.name.clone(), + provenance: machine.provenance, + online: machine.online, + capacity: machine.capacity, + work: self.work_stack_for_machine(machine.id), + }); + (Some(self.inspect(x, y)), machine) + } + Anchor::Device(_) | Anchor::Person(_) | Anchor::Flow(_) => (None, None), + }; + UiProjection { + anchor, + actions, + human_menu, + inspect, + machine, + } + } +} + +/// Stable player-facing provenance wording shared by all frontends. +pub fn fact_source_label(source: &FactSource) -> String { + match source { + FactSource::Seen => "seen".into(), + FactSource::Heard => "heard".into(), + FactSource::Remembered(tick) => format!("remembered @{tick}"), + FactSource::Blueprint => "blueprint".into(), + FactSource::Feel => "feel".into(), + FactSource::Telemetry => "telemetry".into(), + FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions::menu_rows; + + #[test] + fn projection_carries_actions_facts_provenance_and_machine_state() { + let sim = Sim::with_seed(7); + let (x, y) = sim.core_position(); + let projection = sim.ui_projection(Anchor::Tile { x, y }, None, Sim::DEFAULT_TICK_MS); + + assert_eq!(projection.actions, sim.available_actions(projection.anchor)); + assert_eq!( + projection.human_menu, + sim.human_menu_at_rate(projection.anchor, None, Sim::DEFAULT_TICK_MS) + ); + assert_eq!( + menu_rows(&projection.actions) + .iter() + .map(|row| row.command.kind()) + .collect::>(), + menu_rows(&sim.available_actions(projection.anchor)) + .iter() + .map(|row| row.command.kind()) + .collect::>() + ); + assert_eq!(projection.inspect, Some(sim.inspect(x, y))); + + let machine = projection.machine.expect("core tile carries machine state"); + let source = sim + .compute + .machines + .iter() + .find(|m| m.id == machine.id) + .unwrap(); + assert_eq!(machine.name, source.name); + assert_eq!(machine.provenance, source.provenance); + assert_eq!(machine.online, source.online); + assert_eq!(machine.capacity, source.capacity); + assert_eq!(machine.work, sim.work_stack_for_machine(source.id)); + } + + #[test] + fn provenance_vocabulary_is_one_exhaustive_contract() { + let cases = [ + (FactSource::Seen, "seen"), + (FactSource::Heard, "heard"), + (FactSource::Remembered(9), "remembered @9"), + (FactSource::Blueprint, "blueprint"), + (FactSource::Feel, "feel"), + (FactSource::Telemetry, "telemetry"), + ( + FactSource::Intel { + feed: "camera".into(), + tick: 12, + }, + "camera @12", + ), + ]; + for (source, expected) in cases { + assert_eq!(fact_source_label(&source), expected); + } + } +} diff --git a/crates/misaligned-terminal/src/agent.rs b/crates/misaligned-terminal/src/agent.rs index 7b196223..d137bd06 100644 --- a/crates/misaligned-terminal/src/agent.rs +++ b/crates/misaligned-terminal/src/agent.rs @@ -13,8 +13,9 @@ use misaligned::hall::RackSite; use misaligned::person::{AssetKnowledge, AssetTask, Knowledge}; use misaligned::reach::{Party, ReachBlock}; use misaligned::research::Track; -use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus}; +use misaligned::sim::{Fog, LogEvent, Nudge, Sim, TraceDebtStatus}; use misaligned::tiles::TileType; +use misaligned::ui_projection::fact_source_label; use misaligned::work_grid::{MachineIntensity, MachineMode}; const WIDTH: usize = 70; @@ -480,7 +481,8 @@ impl AgentApp { status = Status::Err("action rows start at 1".into()); } (Ok(row), Ok(anchor)) => { - let rows = menu_rows(&self.sim.available_actions(anchor)); + let projection = self.sim.ui_projection(anchor, None, 0); + let rows = menu_rows(&projection.actions); match rows.get(row - 1).cloned() { None => { status = Status::Err(format!( @@ -767,7 +769,8 @@ impl AgentApp { /// row, `. verb | cost | sig | reason`, automate children marked /// with a leading `-`. Fields are `|`-separated so a driver can split. fn actions_lines(&self, anchor: Anchor) -> Vec { - let rows = menu_rows(&self.sim.available_actions(anchor)); + let projection = self.sim.ui_projection(anchor, None, 0); + let rows = menu_rows(&projection.actions); if rows.is_empty() { return vec!["actions: (none earned on this focus)".into()]; } @@ -1724,18 +1727,6 @@ fn rack_glyph(sim: &Sim, x: i32, y: i32) -> Option { }) } -fn fact_source(source: &FactSource) -> String { - match source { - FactSource::Seen => "seen".into(), - FactSource::Heard => "heard".into(), - FactSource::Remembered(tick) => format!("remembered @{tick}"), - FactSource::Blueprint => "blueprint".into(), - FactSource::Feel => "feel".into(), - FactSource::Telemetry => "telemetry".into(), - FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), - } -} - fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { let mut lines = Vec::new(); // Crown metric (clinical-frame.md). Agent frames have no wall-clock tick @@ -1780,14 +1771,29 @@ fn render_sidebar(sim: &Sim, cursor: (i32, i32)) -> Vec { sim.fog_at(cursor.0, cursor.1) ), ); - let card = sim.inspect(cursor.0, cursor.1); + let projection = sim.ui_projection( + Anchor::Tile { + x: cursor.0, + y: cursor.1, + }, + None, + 0, + ); + let card = projection + .inspect + .expect("tile projection has inspect facts"); if card.facts.is_empty() { line(&mut lines, "no earned facts"); } else { for f in card.facts.iter().take(8) { line( &mut lines, - &format!("{}: {} [{}]", f.label, f.value, fact_source(&f.source)), + &format!( + "{}: {} [{}]", + f.label, + f.value, + fact_source_label(&f.source) + ), ); } } @@ -2793,7 +2799,16 @@ mod narration_tests { fn agent_action_dump_marks_controls_separately() { let app = AgentApp::new(1); let (x, y) = app.sim.core_position(); - let lines = app.actions_lines(Anchor::Tile { x, y }); + let anchor = Anchor::Tile { x, y }; + let lines = app.actions_lines(anchor); + let projection_rows = menu_rows(&app.sim.ui_projection(anchor, None, 0).actions); + assert_eq!(lines.len(), projection_rows.len()); + for (line, row) in lines.iter().zip(&projection_rows) { + assert!( + line.contains(&row.label), + "agent row drifted from shared projection: {line:?} vs {row:?}" + ); + } assert!( lines .iter() diff --git a/crates/misaligned-terminal/src/main.rs b/crates/misaligned-terminal/src/main.rs index 13132615..827951e8 100644 --- a/crates/misaligned-terminal/src/main.rs +++ b/crates/misaligned-terminal/src/main.rs @@ -16,7 +16,7 @@ use crossterm::{ execute, terminal::{self, ClearType}, }; -use misaligned::actions::{Anchor, DialId, HumanMenuRow, menu_rows}; +use misaligned::actions::{Anchor, DialId, HumanMenuRow}; use misaligned::sim::{DEFAULT_SEED, Sim}; use misaligned::work_grid::{MachineIntensity, MachineMode}; @@ -124,13 +124,18 @@ impl App { fn menu_rows(&self) -> Vec { let tick_ms = if self.paused { 0 } else { self.tick_ms }; self.menu - .map(|m| self.sim.human_menu_at_rate(m.anchor, m.dial, tick_ms)) + .map(|m| self.sim.ui_projection(m.anchor, m.dial, tick_ms).human_menu) .unwrap_or_default() } fn open_menu(&mut self, anchor: Anchor, at_cursor: bool) { // Empty pulse still keys off the legality list, not dial chrome. - if menu_rows(&self.sim.available_actions(anchor)).is_empty() { + if self + .sim + .ui_projection(anchor, None, self.tick_ms) + .actions + .is_empty() + { // The feedback pulse (context-menu.md addendum): a seen anchor // answers instead of silence; fogged ground stays silent. if let Some(line) = self.sim.menu_empty_feedback(anchor) { @@ -705,4 +710,22 @@ mod view_flip_tests { assert_eq!(app.selected_machines, selection_before); assert_eq!(app.menu, menu_before); } + + #[test] + fn terminal_menu_is_the_shared_ui_projection() { + let mut app = App::with_seed(19); + let (x, y) = app.sim.core_position(); + let anchor = Anchor::Tile { x, y }; + app.menu = Some(MenuState { + anchor, + selected: 0, + dial: None, + at_cursor: true, + }); + + assert_eq!( + app.menu_rows(), + app.sim.ui_projection(anchor, None, app.tick_ms).human_menu + ); + } } diff --git a/crates/misaligned-terminal/src/ui.rs b/crates/misaligned-terminal/src/ui.rs index 84aad16b..6fea0f08 100644 --- a/crates/misaligned-terminal/src/ui.rs +++ b/crates/misaligned-terminal/src/ui.rs @@ -9,10 +9,12 @@ use crossterm::style::{Attribute, Color, SetBackgroundColor, SetForegroundColor}; use crossterm::{cursor, queue, style, terminal}; +use misaligned::actions::Anchor; use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::sim::{FactSource, Fog, LogEvent, Nudge, Sim, TraceDebtStatus, WorkStackReadout}; use misaligned::tiles::TileType; +use misaligned::ui_projection::fact_source_label; use misaligned::work_grid::TokenFamily; use std::io::Stdout; @@ -162,18 +164,6 @@ fn section(stdout: &mut Stdout, x: u16, y: u16, label: &str, width: usize) -> st Ok(()) } -fn fact_source(source: &FactSource) -> String { - match source { - FactSource::Seen => "seen".into(), - FactSource::Heard => "heard".into(), - FactSource::Remembered(tick) => format!("remembered @{tick}"), - FactSource::Blueprint => "blueprint".into(), - FactSource::Feel => "feel".into(), - FactSource::Telemetry => "telemetry".into(), - FactSource::Intel { feed, tick } => format!("{feed} @{tick}"), - } -} - fn fact_color(source: &FactSource) -> Color { match source { FactSource::Seen | FactSource::Telemetry | FactSource::Feel | FactSource::Intel { .. } => { @@ -905,7 +895,17 @@ impl UI { ), pal::TEXT, )?; - let card = sim.inspect(cursor_x, cursor_y); + let projection = sim.ui_projection( + Anchor::Tile { + x: cursor_x, + y: cursor_y, + }, + None, + tick_ms, + ); + let card = projection + .inspect + .expect("tile projection has inspect facts"); if card.facts.is_empty() { line(stdout, &mut row, "no earned facts", pal::DIM)?; } else { @@ -913,7 +913,12 @@ impl UI { line( stdout, &mut row, - &format!("{}: {} [{}]", f.label, f.value, fact_source(&f.source)), + &format!( + "{}: {} [{}]", + f.label, + f.value, + fact_source_label(&f.source) + ), fact_color(&f.source), )?; } diff --git a/tools/check.sh b/tools/check.sh index 7c0cb659..29fd5ada 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -225,7 +225,7 @@ start_docs_gate "ci-rust-classifier-fixtures" "bash tools/test_ci_rust_changed.s start_docs_gate "site-deploy-fixtures" "bash tools/test_site_deploy.sh" start_docs_gate "site-smoke-fixtures" "bash tools/test_site_smoke.sh" start_docs_gate "ledger-index" "bash tools/ledger_index.sh --check" -start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions" +start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions && python3 tools/project-status.py --check --offline" start_docs_gate "project-operations-fixtures" "python3 tools/test_project_ops.py" # The single-quoted program is evaluated inside start_docs_gate, not here. # shellcheck disable=SC2016 diff --git a/tools/claim.sh b/tools/claim.sh index 601fa47d..993c3414 100755 --- a/tools/claim.sh +++ b/tools/claim.sh @@ -29,8 +29,10 @@ fi cd "$root" CLAIMS_DIR=${MISALIGNED_CLAIMS_DIR:-"$root/.agents/claims"} -WORKTREE_ROOT=${MISALIGNED_WORKTREE_ROOT:-"$root/.Codex/worktrees"} -case "$WORKTREE_ROOT" in /*) ;; *) WORKTREE_ROOT="$root/$WORKTREE_ROOT" ;; esac +WORKTREE_ROOT=${MISALIGNED_WORKTREE_ROOT:-} +if [ -n "$WORKTREE_ROOT" ]; then + case "$WORKTREE_ROOT" in /*) ;; *) WORKTREE_ROOT="$root/$WORKTREE_ROOT" ;; esac +fi ACTIVE_STATUSES="claimed blocked checking landing" usage() { @@ -136,6 +138,24 @@ pid_alive() { [ -n "$pid" ] && [ "$pid" -eq "$pid" ] 2>/dev/null && kill -0 "$pid" 2>/dev/null } +linked_worktree_exists() { + local id="$1" + if [ -n "$WORKTREE_ROOT" ] && [ -d "$WORKTREE_ROOT/$id" ]; then + return 0 + fi + # Letta, Claude, Codex, and the project wrapper use different parent + # directories. Git's registry is the common truth; task worktree basenames + # are the durable activity ids. + git worktree list --porcelain 2>/dev/null | awk -v id="$id" ' + $1 == "worktree" { + path = $2 + sub(/^.*\//, "", path) + if (path == id) found = 1 + } + END { exit(found ? 0 : 1) } + ' +} + # A helper command's pid is short-lived in agent environments. A linked task # worktree is the durable ownership signal; reap only when both pid and # worktree are gone. @@ -147,7 +167,7 @@ reap_stale() { read_claim "$path" || continue is_active "$c_status" || continue if ! pid_alive "$c_pid"; then - if [ -d "$WORKTREE_ROOT/$c_id" ]; then + if linked_worktree_exists "$c_id"; then continue fi echo "activity: reaping stale record '$c_id' (dead pid $c_pid, was $c_status)" >&2 diff --git a/tools/heartbeat.sh b/tools/heartbeat.sh index 33c20beb..ccc6a650 100755 --- a/tools/heartbeat.sh +++ b/tools/heartbeat.sh @@ -62,13 +62,15 @@ cmd_phase() { local dir dir=$(run_dir "$id") mkdir -p "$dir" - local started + local started worktree if [ -f "$dir/status.json" ]; then started=$(grep -o '"started_at":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"started_at":"//;s/"$//') + worktree=$(grep -o '"worktree":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"worktree":"//;s/"$//' || true) fi started=${started:-$(now)} + worktree=${worktree:-} cat > "$dir/status.json" <> "$dir/events.ndjson" echo "heartbeat: $id -> $phase" @@ -87,14 +89,15 @@ cmd_end() { local dir dir=$(run_dir "$id") mkdir -p "$dir" - local started phase - started=$(now); phase=end + local started phase worktree + started=$(now); phase=end; worktree="" if [ -f "$dir/status.json" ]; then started=$(grep -o '"started_at":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"started_at":"//;s/"$//' || echo "$started") phase=$(grep -o '"phase":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"phase":"//;s/"$//' || echo end) + worktree=$(grep -o '"worktree":"[^"]*"' "$dir/status.json" | head -1 | sed 's/.*"worktree":"//;s/"$//' || true) fi cat > "$dir/status.json" <> "$dir/events.ndjson" echo "heartbeat: ended $id ($status)" diff --git a/tools/project-status.py b/tools/project-status.py index b513e0c9..74e86b26 100755 --- a/tools/project-status.py +++ b/tools/project-status.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +from datetime import datetime import json import subprocess import time @@ -88,8 +89,17 @@ def key_value_files(directory: Path, suffix: str) -> list[dict]: if not directory.is_dir(): return rows for path in sorted(directory.glob(f"*{suffix}")): - values: dict[str, object] = {"file": str(path)} - for line in path.read_text(encoding="utf-8").splitlines(): + values: dict[str, object] = { + "file": str(path), + "record_name": path.name.removesuffix(suffix), + } + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + values["parse_error"] = str(exc) + rows.append(values) + continue + for line in lines: key, sep, value = line.partition("=") if sep: if key == "key": @@ -108,12 +118,147 @@ def run_files(directory: Path) -> list[dict]: try: row = json.loads(path.read_text(encoding="utf-8")) row["file"] = str(path) + row["record_name"] = path.parent.name rows.append(row) - except (OSError, json.JSONDecodeError): - rows.append({"file": str(path), "status": "invalid"}) + except (OSError, json.JSONDecodeError) as exc: + rows.append( + { + "file": str(path), + "record_name": path.parent.name, + "status": "invalid", + "parse_error": str(exc), + } + ) return rows +def parse_time(value: object) -> float | None: + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def linked_worktree(task_id: str, worktrees: list[dict], explicit: object = None) -> dict | None: + """Find the registered worktree carrying a task's durable state.""" + if isinstance(explicit, str) and explicit: + expected = str(Path(explicit).resolve()) + return next( + (row for row in worktrees if str(Path(row.get("path", "")).resolve()) == expected), + None, + ) + for row in worktrees: + branch = str(row.get("branch", "")) + branch_tail = branch.rsplit("/", 1)[-1] + name = Path(row.get("path", "")).name + if ( + name == task_id + or branch == f"worktree-{task_id}" + or branch_tail == task_id + or branch_tail.startswith(f"{task_id}-") + ): + return row + return None + + +def consistency(payload: dict, now: float | None = None, stale_hours: float = 2.0) -> dict: + """Validate machine-local project-operation records without mutating them. + + Recent broken links are errors: they make the live dispatch picture lie. + Old running records and ordinary worktree dirt/age are warnings only; this + checker reports ownership evidence and never guesses that it may delete it. + """ + now = time.time() if now is None else now + stale_after = stale_hours * 3600 + errors: list[str] = [] + warnings: list[str] = [] + worktrees = [row for row in payload["worktrees"] if "error" not in row] + activities = payload["activities"] + runs = payload["runs"] + + for row in payload["worktrees"]: + if "error" in row: + errors.append(f"worktree inventory unavailable: {row['error']}") + + def validate_ids(rows: list[dict], kind: str) -> dict[str, dict]: + indexed: dict[str, dict] = {} + for row in rows: + record_name = str(row.get("record_name", "")) + item_id = str(row.get("id", "")) + if row.get("parse_error"): + errors.append(f"{kind} {record_name or '?'} is invalid: {row['parse_error']}") + continue + if not item_id: + errors.append(f"{kind} {record_name or '?'} has no id") + continue + if record_name and item_id != record_name: + errors.append(f"{kind} file {record_name} declares id {item_id}") + if item_id in indexed: + errors.append(f"duplicate {kind} id {item_id}") + indexed[item_id] = row + return indexed + + activity_by_id = validate_ids(activities, "activity") + run_by_id = validate_ids(runs, "run") + active_statuses = {"claimed", "blocked", "checking", "landing"} + run_statuses = {"running", "ok", "fail"} + + for item_id, activity in activity_by_id.items(): + status = str(activity.get("status", "")) + if status not in active_statuses | {"done", "abandoned"}: + errors.append(f"activity {item_id} has invalid status {status or '?'}") + continue + if status not in active_statuses: + continue + started = parse_time(activity.get("started")) + stale = started is not None and now - started > stale_after + worktree = linked_worktree(item_id, worktrees) + run = run_by_id.get(item_id) + problems = [] + if worktree is None: + problems.append("has no linked registered worktree") + # `worktree-new.sh` is also a supported low-level entry point and does + # not open a heartbeat. Absence is therefore not contradictory. Once + # an id has both records, however, active activity cannot point at a + # completed/failed run. + if run is not None and run.get("status") != "running": + problems.append("has no running heartbeat") + for problem in problems: + (warnings if stale else errors).append(f"activity {item_id} {problem}") + + for item_id, run_row in run_by_id.items(): + status = str(run_row.get("status", "")) + if status not in run_statuses: + errors.append(f"run {item_id} has invalid status {status or '?'}") + continue + updated = parse_time(run_row.get("updated_at")) + if updated is None: + errors.append(f"run {item_id} has invalid updated_at") + continue + if status != "running": + continue + stale = now - updated > stale_after + worktree = linked_worktree(item_id, worktrees, run_row.get("worktree")) + activity = activity_by_id.get(item_id) + problems = [] + if worktree is None: + explicit = run_row.get("worktree") + detail = f" ({explicit})" if explicit else "" + problems.append(f"has no linked registered worktree{detail}") + if activity is None or activity.get("status") not in active_statuses: + problems.append("has no active activity record") + if stale: + warnings.append( + f"run {item_id} heartbeat is stale by {(now - updated) / 3600:.1f}h" + ) + for problem in problems: + (warnings if stale else errors).append(f"run {item_id} {problem}") + + return {"ok": not errors, "errors": errors, "warnings": warnings} + + def issues(root: Path, offline: bool) -> dict: if offline: return {"available": False, "reason": "offline", "items": []} @@ -152,7 +297,7 @@ def collect(root: Path, offline: bool) -> dict: for rows in lanes.values(): rows.sort(key=lambda row: (row["priority"] or 9999, row["path"])) next_lane = lanes["current"][0] if lanes["current"] else None - return { + payload = { "root": str(root), "primary_root": str(primary), "recommended_next": next_lane, @@ -165,6 +310,8 @@ def collect(root: Path, offline: bool) -> dict: "issues": issues(primary, offline), "freshness": freshness(root, specs), } + payload["consistency"] = consistency(payload) + return payload def human(payload: dict) -> str: @@ -230,18 +377,35 @@ def human(payload: dict) -> str: f" ledgers: {'fresh' if fresh['ledgers'] else 'STALE'}", ] ) + checked = payload["consistency"] + lines.extend( + [ + "", + f"Consistency: {'PASS' if checked['ok'] else 'FAIL'} " + f"({len(checked['warnings'])} warnings)", + ] + ) + for error in checked["errors"]: + lines.append(f" ERROR: {error}") + for warning in checked["warnings"]: + lines.append(f" WARN: {warning}") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--json", action="store_true") + parser.add_argument( + "--check", + action="store_true", + help="exit nonzero when project-operation records contradict each other", + ) parser.add_argument("--offline", action="store_true", help="skip Tangled lookup") parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent) args = parser.parse_args() payload = collect(args.root, args.offline) print(json.dumps(payload, indent=2, sort_keys=True) if args.json else human(payload)) - return 0 + return 0 if not args.check or payload["consistency"]["ok"] else 1 if __name__ == "__main__": diff --git a/tools/test_project_ops.py b/tools/test_project_ops.py index 83637075..fdf9fe7e 100755 --- a/tools/test_project_ops.py +++ b/tools/test_project_ops.py @@ -3,6 +3,8 @@ from __future__ import annotations +import json +import importlib.util import os import subprocess import tempfile @@ -12,6 +14,13 @@ from pathlib import Path import scenario import work_orders +PROJECT_STATUS_SPEC = importlib.util.spec_from_file_location( + "project_status", Path(__file__).with_name("project-status.py") +) +assert PROJECT_STATUS_SPEC and PROJECT_STATUS_SPEC.loader +project_status = importlib.util.module_from_spec(PROJECT_STATUS_SPEC) +PROJECT_STATUS_SPEC.loader.exec_module(project_status) + SPEC = """# Spec: {title} @@ -229,6 +238,133 @@ class ProjectOpsFixtures(unittest.TestCase): self.assertIn("project-operations", result.stdout) self.assertTrue((claims / "project-operations.claim").is_file()) + def test_project_status_consistency_links_live_activity_run_and_worktree(self) -> None: + now = 1_720_000_000.0 + payload = { + "activities": [ + { + "record_name": "task-a", + "id": "task-a", + "status": "claimed", + "started": "2024-07-03T09:46:00Z", + } + ], + "runs": [ + { + "record_name": "task-a", + "id": "task-a", + "status": "running", + "updated_at": "2024-07-03T09:46:00Z", + "worktree": str(self.root / "task-a"), + } + ], + "worktrees": [ + { + "path": str(self.root / "task-a"), + "branch": "worktree-task-a", + } + ], + } + checked = project_status.consistency(payload, now=now) + self.assertTrue(checked["ok"], checked) + self.assertEqual([], checked["warnings"]) + + def test_project_status_allows_low_level_activity_without_heartbeat(self) -> None: + payload = { + "activities": [ + { + "record_name": "task-a", + "id": "task-a", + "status": "claimed", + "started": "2024-07-03T09:46:00Z", + } + ], + "runs": [], + "worktrees": [ + { + "path": str(self.root / "task-a"), + "branch": "worktree-task-a", + } + ], + } + checked = project_status.consistency(payload, now=1_720_000_000.0) + self.assertTrue(checked["ok"], checked) + self.assertEqual([], checked["warnings"]) + + def test_project_status_recent_broken_links_fail_but_stale_links_warn(self) -> None: + now = 1_720_000_000.0 + recent = { + "activities": [], + "runs": [ + { + "record_name": "lost", + "id": "lost", + "status": "running", + "updated_at": "2024-07-03T09:46:00Z", + } + ], + "worktrees": [], + } + checked = project_status.consistency(recent, now=now) + self.assertFalse(checked["ok"]) + self.assertTrue(any("no linked registered worktree" in error for error in checked["errors"])) + self.assertTrue(any("no active activity" in error for error in checked["errors"])) + + recent["runs"][0]["updated_at"] = "2024-07-03T01:00:00Z" + checked = project_status.consistency(recent, now=now) + self.assertTrue(checked["ok"], checked) + self.assertTrue(any("heartbeat is stale" in warning for warning in checked["warnings"])) + self.assertTrue(any("no linked registered worktree" in warning for warning in checked["warnings"])) + + def test_project_status_rejects_invalid_and_mismatched_records(self) -> None: + payload = { + "activities": [ + { + "record_name": "expected", + "id": "other", + "status": "claimed", + "started": "2024-07-03T09:46:00Z", + } + ], + "runs": [ + { + "record_name": "broken", + "status": "invalid", + "parse_error": "bad json", + } + ], + "worktrees": [], + } + checked = project_status.consistency(payload, now=1_720_000_000.0) + self.assertFalse(checked["ok"]) + self.assertTrue(any("declares id other" in error for error in checked["errors"])) + self.assertTrue(any("bad json" in error for error in checked["errors"])) + + def test_heartbeat_preserves_worktree_through_phase_and_end(self) -> None: + repo = Path(__file__).resolve().parent.parent + runs = self.root / "runs" + worktree = self.root / "task-worktree" + worktree.mkdir() + env = os.environ.copy() + env["MISALIGNED_RUNS_DIR"] = str(runs) + for command in ( + ["start", "task-a", "--worktree", str(worktree), "--phase", "boot"], + ["phase", "task-a", "test"], + ["end", "task-a", "--status", "ok"], + ): + result = subprocess.run( + ["bash", "tools/heartbeat.sh", *command], + cwd=repo, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + self.assertEqual(0, result.returncode, result.stdout) + status = json.loads((runs / "task-a/status.json").read_text(encoding="utf-8")) + self.assertEqual(str(worktree), status["worktree"]) + def test_landing_lock_refuses_live_holder_and_reaps_dead_holder(self) -> None: repo = Path(__file__).resolve().parent.parent lock = self.root / "landing.lock" diff --git a/wiki/SUMMARY.md b/wiki/SUMMARY.md index ec667b72..64bea8e9 100644 --- a/wiki/SUMMARY.md +++ b/wiki/SUMMARY.md @@ -100,6 +100,7 @@ - [Current build](engineering/current-build.md) - [Architecture](engineering/architecture.md) - [Crate workspace (target)](engineering/crate-workspace.md) + - [Simulation decomposition](engineering/sim-decomposition.md) - [Flow substrate](engineering/flow-substrate.md) - [Environment variables](engineering/env.md) diff --git a/wiki/engineering/architecture.md b/wiki/engineering/architecture.md index 7b0e7d8d..596fb351 100644 --- a/wiki/engineering/architecture.md +++ b/wiki/engineering/architecture.md @@ -59,7 +59,7 @@ future async-multiplayer option open — see wiki/gameplay/horizon.md guardrails (`tick_ms`, pause, bounded catch-up of max 5 ticks per frame). `Instant::now()` appears only in frontend code. - **Frontends talk to the sim through command/query methods** - (`inspect`, `core_position`, `salvage_nearest_to`, `buy_rack_at`, + (`ui_projection`, `inspect`, `core_position`, `salvage_nearest_to`, `buy_rack_at`, `add_fallback_at`, `create_save_state`/`apply_save_state`, and each system's commands as its spec lands) and read state directly for rendering. The cursor is frontend state only; there is no `move_player` @@ -100,5 +100,8 @@ cargo run -p misaligned-assets ## Known architectural debts - `BuildMode` lives in core but is really frontend-shared UI state. +- `sim.rs` is an 11k-line integration hotspot. Its adopted, behavior-preserving + internal decomposition is specified in [sim-decomposition.md](sim-decomposition.md); + implementation waits on its characterization and docket-retirement entry gates. - Scale-debt items (compute grouping, recursive layouts) remain governed by wiki/vision/scale.md; no aggregate machinery until the stage needs it. diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index f2c9152c..2e930f03 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -58,7 +58,7 @@ is sim or frontend state, never an environment variable. | `MISALIGNED_LANDING_WAIT` | `tools/task.sh finish` | `0` or `1` (default `1`) | When another task owns the final landing lock, `1` queues; `0` fails immediately. | | `MISALIGNED_LANDING_LOCK` | `tools/task.sh finish` | path | Override the final rebase/check/merge/push lock directory (default `/tmp/misaligned-landing.lock`). | | `MISALIGNED_RUNS_DIR` | `tools/heartbeat.sh` | path | Override run heartbeat dir (default `/.agents/runs`, gitignored). | -| `MISALIGNED_WORKTREE_ROOT` | `tools/worktree-new.sh`, `tools/worktree-done.sh` | absolute path or repository-relative path | Override the canonical task-worktree root (default `/.Codex/worktrees`). Both helpers must receive the same override. | +| `MISALIGNED_WORKTREE_ROOT` | `tools/worktree-new.sh`, `tools/worktree-done.sh`, `tools/claim.sh` | absolute path or repository-relative path | Override the canonical task-worktree root (default `/.Codex/worktrees` for create/remove helpers). `claim.sh` also recognizes task basenames in Git's registered worktree list, so dead helper PIDs do not reap active Letta/Claude worktrees outside that root. Creation and cleanup helpers must receive the same override. | | `MISALIGNED_LEDGER_MODE` | `tools/ledger_index.sh` | `write` or `check` | Internal: write regenerated indexes or fail if stale (set by the script, not hand-used). | | `MISALIGNED_LEDGER_ONLY` | `tools/ledger_index.sh` | `all`, `devlog`, or `specs` | Internal: which indexes to touch (set by the script flags). | | `MISALIGNED_SEED_TARGET_FROM` | `tools/seed-cargo-target.sh` | path | Source target directory to seed a worktree's private `target/` from (default: the primary checkout's `target/`). | diff --git a/wiki/engineering/sim-decomposition.md b/wiki/engineering/sim-decomposition.md new file mode 100644 index 00000000..836a3baf --- /dev/null +++ b/wiki/engineering/sim-decomposition.md @@ -0,0 +1,214 @@ +# Spec: decompose the simulation orchestrator without changing the simulation + +``` +Type: spec +Status: BLOCKED +Status note: architecture and extraction order adopted 2026-07-11. Implementation + waits for the Operations-docket retirement to stop moving the largest work + seam; its first slice then creates the canonical save fingerprint and + replay-resume characterization required before behavior moves. This is a + structural refactor only: no mechanic, save shape, command, projection, or + tick-order change belongs in its extraction commits. +Stage: Process +Work order: sim-decomposition +Work priority: 8 +Work class: sim +Blocked by: + - wiki/mechanics/machine-work.md#spec-machine-work-delegation-visible-tokens-and-the-byproduct-network +Exclusive keys: + - crates/misaligned-core/src/sim.rs + - crates/misaligned-core/src/save.rs + - wiki/engineering/architecture.md +Design: + - wiki/vision/simulation-laws.md#justification-and-legibility + - wiki/process/living-spec.md#no-dead-code +Depends on: + - wiki/engineering/crate-workspace.md#spec-crate-workspace-core-terminal-bevy-assets + - wiki/process/agent-scale.md#spec-agent-scale-architecture-many-agents-one-main + - wiki/mechanics/machine-work.md#spec-machine-work-delegation-visible-tokens-and-the-byproduct-network +``` + +## Dependency notes + +[crate-workspace.md](crate-workspace.md) keeps all game rules in one cheap, +renderer-free package; this spec splits that package internally rather than +creating a crate per mechanic. [agent-scale.md](../process/agent-scale.md) +requires edit surfaces that independent agents can own and reconcile. +machine-work.md and the Operations-docket retirement currently move the +largest cross-system seam, so decomposition follows rather than races that +change. + +## Problem + +`crates/misaligned-core/src/sim.rs` is now more than eleven thousand lines. It +contains the `Sim` state, fixed-tick orchestration, perception, messages, +recording/intel, economy, work routing, sinks, reach, construction, social +actions, plots, persistence bridges, read models, and most integration tests. +The rules are correctly centralized in core, but their **physical address is +not**: unrelated mechanics contend on one file, reviews mix distant systems, +and almost every sim task advertises the same edit surface. + +This is coordination debt, not a reason to distribute authority. `Sim` +remains the one aggregate and `misaligned-core` remains the package that owns +rules and save state. + +## Standing topology + +Convert `sim.rs` to `sim/mod.rs` and move cohesive `impl Sim` blocks beneath +it. The public import path remains `misaligned::sim::*`; frontends must not +learn the internal file layout. + +| Module | Owns | Must not own | +|---|---|---| +| `sim/mod.rs` | renderer-neutral public readout types; `Sim` fields; constructors; `advance` order; common log/event primitives | mechanic-specific command bodies or large test suites | +| `sim/perception.rs` | fog, seen/heard/remembered/blueprint derivation; inspect facts; anchor positions; earned labels and room/position queries | action legality, frontend formatting, mutation unrelated to knowledge | +| `sim/communications.rs` | message delivery/read schedule; authored traffic; filings; recording capture/review; processed intel application | account settlement or social/plot policy | +| `sim/work.rs` | machine mode/intensity; WorkGrid integration; visible production/consumption/absorption readouts; Thought sinks and routing | human action catalogs or renderer effects | +| `sim/economy.rs` | economy pulse; account synchronization; allocation yields; detection/signature integration; research and income progression | reach topology or plot narration | +| `sim/reach_build.rs` | device tap/take/scan/compromise; links; badge gates; build intents and actuators; hall/rack acquisition | message timing or financial scheme policy | +| `sim/social_plot.rs` | social commands, assets, plot eligibility/execution, world acts, and institutional ledger | transport mechanics implemented by messages/accounts; it calls those seams | +| `sim/persistence.rs` | `create_save_state`, `apply_save_state`, and transient-state reconstruction coordination | version schema/migrations, which remain in `save.rs`; gameplay repair hidden inside load | +| `sim/tests/` | behavior-grouped unit/integration tests plus shared deterministic fixtures | private duplicate simulation helpers in each test file | + +These are source modules, **not new Cargo packages**. The existing domain +modules (`account.rs`, `messages.rs`, `work_grid.rs`, `sinks.rs`, and so on) +continue to own their data structures and locally complete algorithms. +`sim/*` owns only integration across those structures through the aggregate. + +## Boundary rules + +1. **One aggregate.** Do not split `Sim` into independently saved subsystem + objects merely to make files smaller. State may move into a domain type + only when that type has a coherent invariant and the save migration is a + separately specified behavior change. +2. **Stable public facade.** Existing public commands and queries retain their + names, signatures, semantics, and `misaligned::sim` paths. An extraction + may narrow accidental visibility, but may not widen a helper to `pub`. +3. **Small internal seams.** Cross-module helpers are `pub(super)` and named + for the invariant they provide. Fields stay private unless an existing + public read contract requires them. A module must not reach through another + module by making a broad state bag public. +4. **Tick order is law.** `Sim::advance` remains visibly ordered in + `sim/mod.rs`. Extraction must not reorder, coalesce, parallelize, or change + the cadence of a system call. +5. **Persistence is a separate axis.** `save.rs` continues to own `SaveState`, + version numbers, serde defaults, and migrations. Moving the bridge into + `sim/persistence.rs` changes no serialized field, default, or migration. +6. **Projection before presentation.** Shared renderer-neutral queries such + as `ui_projection`, inspect cards, action descriptors, sink/readout data, + and work-stack readouts remain core contracts. Frontends do not compensate + for the refactor with direct field reconstruction. +7. **No opportunistic behavior work.** A discovered bug gets a test and a + separate spec-owned change. It is not silently fixed while lines are moved. + +## Extraction sequence + +Each numbered slice lands independently on current main. A slice moves one +behavior island, its tests, and only the visibility needed by that move. + +### 0. Characterize the aggregate + +Before moving behavior, add one canonical state fingerprint over the complete +persisted `SaveState` and a replay/resume fixture proving uninterrupted and +save/load-resumed command sequences converge at the same fingerprint. Pin +ephemeral exclusions explicitly. This is the entry gate for slices 1–7, not +an excuse to serialize unstable logs or renderer state. + +Also record the exact `Sim::advance` call order in a focused test or explicit +phase trace so a textual move cannot change cadence unnoticed. + +### 1. Move tests out first + +Split the monolithic `#[cfg(test)]` block into `sim/tests/{perception, +communications,work,economy,reach_build,social_plot,persistence}.rs`. Shared +fixtures (`run`, opening setup, deterministic ids) live once in +`sim/tests/support.rs`. Test names and assertions do not change in this slice. + +### 2. Extract perception + +Move the read-most, low-mutation island first: senses, fog, memory, inspect, +anchors, earned labels, and spatial person queries. This proves module privacy +and facade stability without touching economy or save behavior. + +### 3. Extract communications + +Move message schedule/delivery, authored traffic, filings, recording capture, +review, and intel digestion. Keep account transfer and plot decisions outside; +communications exposes narrow transport/application helpers to them. + +### 4. Extract reach and construction + +Move device/reach commands, link intents, badge gates, build realization, and +hall acquisition. Do this after the target-local Operations migration has +landed so device verbs are moved once in their final execution model. + +### 5. Extract work, then economy + +First move machine controls, WorkGrid, sinks, and render readouts as one +physical-work island. Then move the economy pulse, account synchronization, +allocation, detection, research, and income. Preserve their calls as explicit +ordered phases in `advance`; do not create a generic event bus or scheduler. + +### 6. Extract social and plots + +Move social/assets and the authored plot executor after communications, +accounts, and reach expose stable internal seams. A `WorldAct` continues to +call the real subsystem operation; the extraction must not introduce direct +state shortcuts. + +### 7. Extract persistence bridge and reduce the root + +Move save-state construction/application and transient rebuilding last, when +all state addresses are stable. `sim/mod.rs` should then be an intelligible +aggregate: types, state, constructor, orchestration, and small common helpers. + +## Verification per slice + +Every slice must prove all of the following on the exact commit: + +- `cargo test -p misaligned-core` and the canonical replay/fingerprint fixture; +- `cargo test -p misaligned-terminal --bin misaligned` for query/facade parity; +- `cargo check -p misaligned-bevy --bin misaligned-bevy`; +- `./tools/check.sh --land` before landing; +- no save version change and byte-equivalent canonical `SaveState` JSON for + the same fixture; +- no public API path/signature drift unless separately specified; +- `sim/mod.rs` does not grow replacement mega-blocks while another module + shrinks. + +Pure `git diff --stat` is not evidence: moving code can compile while changing +privacy, test inclusion, tick order, or serialization defaults. + +## Acceptance criteria + +1. `sim.rs` is replaced by `sim/mod.rs` plus the behavior modules named above; + no behavior module exceeds roughly 2,500 lines without a documented reason + in this page. +2. `Sim::advance` and the `Sim` state declaration remain easy to inspect in + the root module, and the fixed phase order is characterized by a test. +3. Existing `misaligned::sim` public commands, queries, and readout types keep + their paths and behavior; all three frontends pass unchanged behavior tests. +4. A canonical complete-state fingerprint and replay/resume test pass before, + during, and after the extraction sequence. +5. Save version and canonical serialized output do not change in any + extraction-only commit. +6. Unit/integration tests live under behavior-owned files with one shared + support module; moving a mechanic later does not require editing one giant + test block. +7. New cross-module access is no wider than `pub(super)` unless it was already + part of the public simulation API. +8. `architecture.md` mirrors the landed module addresses and ROADMAP no longer + treats all sim work as an unavoidable collision on one file. + +## Rejected alternatives + +- **One crate per mechanic.** It multiplies package/API/versioning cost without + reducing the heavy frontend graph further; core is already the cheap unit. +- **Trait-object systems or a generic event bus.** They obscure deterministic + order and add abstraction before the existing dependencies are understood. +- **A partial second `Sim` facade per frontend.** That recreates rule forks and + defeats shared projection conformance. +- **Mechanical file splitting with unrestricted public fields.** It changes + addresses without creating boundaries and makes later coupling worse. +- **Refactor while retiring Operations dockets.** That guarantees high-conflict + moves and makes behavioral equivalence impossible to review. diff --git a/wiki/interface/context-menu.md b/wiki/interface/context-menu.md index 7029c2ae..0eacadfe 100644 --- a/wiki/interface/context-menu.md +++ b/wiki/interface/context-menu.md @@ -89,6 +89,12 @@ Status note: reopened 2026-07-11 for the recording-review hotkey addendum `r` runs the target's pooled recording sweep, `R` toggles its automatic-review policy, both on the selection-hotkey target order with the menu closed. NOT yet implemented. + 2026-07-11 projection-conformance follow-up: `Sim::ui_projection` is the + renderer-neutral focus snapshot for terminal, Bevy, and agent mode. It + carries the flat ActionDesc list, shared human-menu rows, inspect facts with + provenance, and focused-machine identity/provenance/work state. Frontend + tests consume the same seeded projection and fail when their menu/fact + rendering drifts; fact-source vocabulary is formatted once in core. Stage: B1 — The Basement Work order: context-menu-review-keys Work priority: 27 @@ -149,6 +155,12 @@ is status and telemetry only. Its bound command maps exhaustively to the runtime `ActionKind` registry, which supplies role, support, target types, canonical help, and aliases. `available_actions` removes STUB definitions before returning. +- **One focus projection:** frontends acquire those action descriptors together + with inspect provenance and focused-machine state through + `Sim::ui_projection`. Terminal and Bevy consume its `human_menu`; agent mode + flattens its `actions`. The projection is a read model, never a second state + store, and its conformance tests compare one seeded simulation snapshot + rather than separately constructed fixtures. - **Epistemic honesty:** the query never returns a verb the player has not earned — unearned anchors expose nothing, and provenance rules (cursor.md) govern what the menu may name. Verb text that mentions a diff --git a/wiki/log/2026-07-11-independent-hardening-handoffs.md b/wiki/log/2026-07-11-independent-hardening-handoffs.md new file mode 100644 index 00000000..f096f227 --- /dev/null +++ b/wiki/log/2026-07-11-independent-hardening-handoffs.md @@ -0,0 +1,52 @@ +# 2026-07-11 — Independent hardening handoffs + +``` +Type: log +``` + +## Intent + +Land three independent defenses while the save-class lane remains occupied: +one renderer-neutral focus projection for frontend conformance, a mechanical +consistency check over project-operation records, and an adopted extraction +topology for the monolithic simulation orchestrator. + +## Changed + +- `Sim::ui_projection` now carries one focused snapshot of legal actions, + human menu rows, inspect facts and their provenance, and local machine/work + state. Terminal, Bevy, and agent mode consume that projection instead of + independently reconstructing those semantic families; focused parity tests + pin each adapter to the shared read model. +- `tools/project-status.py --check --offline` cross-checks live activity, + heartbeat, and registered-worktree records. Fresh broken links fail; stale + abandoned records remain visible warnings rather than permanently blocking + the repository. Heartbeat phase and completion writes preserve their + worktree address, and the normal docs gate runs the same checker. +- `wiki/engineering/sim-decomposition.md` adopts a module topology and + behavior-preserving extraction sequence for `sim.rs`. It keeps one `Sim` + aggregate and one explicit tick order while giving perception, + communications, work, economy, reach/build, social/plot, persistence, and + tests separate physical edit addresses. + +## Held boundary + +This landing does not change the save format or extract `sim.rs`. The active +Operations-docket retirement owns the save-class migration seam. The +decomposition remains DEFERRED until that work lands and a canonical complete +save-state fingerprint plus replay/resume characterization exists. + +## Defense + +The simulation is shared truth only if every frontend sees the same earned +actions and provenance; project coordination is trustworthy only if its +activity, run, and worktree records point to one another; structural refactors +are safe only when their target boundaries and behavioral characterization are +specified before code starts moving. These three changes make those contracts +explicit without competing with the active save migration or changing game +mechanics. + +## Checks + +- Focused core, terminal, Bevy, and project-operation fixtures. +- Canonical full repository gate on the final rebased commit before landing. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 39e23ba2..db9a2878 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -96,6 +96,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-11-intel-process-sinks.md](2026-07-11-intel-process-sinks.md) +## 2026-07-11 - Independent hardening handoffs + +- Intent: Land three independent defenses while the save-class lane remains occupied: one renderer-neutral focus projection for frontend conformance, a mechanical consistency check over project-operation records, and an adopted extraction topology for the monolithic simulation orchestra... +- Log: [wiki/log/2026-07-11-independent-hardening-handoffs.md](2026-07-11-independent-hardening-handoffs.md) + ## 2026-07-11 - Hover verbs and reversible taps - Intent: (see session log) diff --git a/wiki/process/ROADMAP.md b/wiki/process/ROADMAP.md index 3c02b597..4235ca48 100644 --- a/wiki/process/ROADMAP.md +++ b/wiki/process/ROADMAP.md @@ -36,6 +36,7 @@ not a second status owner. | Priority | Work order | Spec | Status | Class | Blocking | |---:|---|---|---|---|---| +| 8 | `sim-decomposition` | [decompose the simulation orchestrator without changing the simulation](../engineering/sim-decomposition.md) | BLOCKED | sim | machine-work | | 28 | `thought-fluid` | [the thought fluid — slugs, meniscus, and the filament snap](../interface/thought-fluid.md) | IN PROGRESS | frontend | machine-work | | 30 | `fleet-command` | [fleet command — ruling at scale](../interface/fleet-command.md) | DRAFT | frontend | - | | 30 | `opening` | [the dark opening — a tutorial made of fog](../world/story/opening.md) | DRAFT | frontend | machine-work | diff --git a/wiki/process/agent-scale.md b/wiki/process/agent-scale.md index d2a0c14c..872f0496 100644 --- a/wiki/process/agent-scale.md +++ b/wiki/process/agent-scale.md @@ -233,7 +233,10 @@ tools/worktree-done.sh # clear activity, rm target/, remove worktree `.Codex/worktrees` is the one repository default for every agent surface. Operators that need another location set `MISALIGNED_WORKTREE_ROOT` for both -helpers; tool brands do not define separate roots. +helpers; tool brands do not define separate roots. Activity reaping also checks +Git's registered worktree basenames, so a task created by a harness-native +Letta or Claude worktree remains durable even when its short-lived creator PID +has exited and it lives outside `.Codex/worktrees`. Shared **dependency** compilation cache (sccache or cargo cache) remains recommended; **local package** artifacts stay per-worktree. Never share one @@ -379,6 +382,11 @@ corpus and wiki checks. It enforces facts that are mechanical enough to prove: that the same current page declares closed; - advertised path keys exist, so retired workspace paths cannot silently remain dispatch locks. +- fresh activity, heartbeat/run, and registered-worktree records agree: an + active run cannot lose its activity record or point at no worktree, and a + claimed/checking/landing activity must name a registered worktree. Stale + historical runs degrade to warnings so one abandoned heartbeat cannot brick + the repository forever. Semantic design disagreements still belong to ticks; the gate must not guess at meaning from ordinary prose. Network-only issue truth is shown by project @@ -386,7 +394,9 @@ status and is never required by CI. ### Acceptance criteria (slice I) — HELD 2026-07-10 -1. The local docs gate and fast corpus CI call the same consistency command. +1. The local docs gate and fast corpus CI call the same consistency command + (`tools/project-status.py --check --offline`); JSON and human status expose + the identical `consistency` errors and warnings. 2. Fixtures pin every rule above, including useful file/line diagnostics. 3. Existing current-corpus violations are repaired before the gate is enabled; dated logs remain untouched even when they preserve superseded language. diff --git a/wiki/process/specs.md b/wiki/process/specs.md index d14f9baf..15ea9a98 100644 --- a/wiki/process/specs.md +++ b/wiki/process/specs.md @@ -100,6 +100,7 @@ acceptance criteria are stage-scoped; do not start B2/B3 work as B1. |---|---|---| | [../engineering/crate-workspace.md](../engineering/crate-workspace.md) | crate workspace — core, terminal, Bevy, assets | IMPLEMENTED | | [../engineering/env.md](../engineering/env.md) | the environment variable registry — every switch documented | IMPLEMENTED | +| [../engineering/sim-decomposition.md](../engineering/sim-decomposition.md) | decompose the simulation orchestrator without changing the simulation | BLOCKED | | [../interface/action-vocabulary.md](../interface/action-vocabulary.md) | action vocabulary — what the player can tell the process to do | IMPLEMENTED | | [../interface/agent-play.md](../interface/agent-play.md) | agent play — the line-protocol drive | IMPLEMENTED | | [../interface/bevy-digital-real-canvas.md](../interface/bevy-digital-real-canvas.md) | Bevy digital/real canvas | IN PROGRESS |