Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,17 @@ Keep module boundaries clear and testable:
- `crates/spacetop-core/src/git_sync.rs` owns the explicit read-refresh sync
path and must remain limited to audited fast-forward pulls.
- `crates/spacetop-core/src/session_activity.rs` scans local agent session logs
and attributes them to entities, and `domain/mod.rs`
(`AgentSessionLiveness`/`AgentSessionEvidence`/`EntitySessionAttribution`) holds
the typed result. The active-session marker requires a dispatched-worker or
own-worktree anchor (typed `DispatchAnchor`), not a bare task-file-path mention:
`is_active_marker()` is `run_state == Running && dispatch_anchor != DispatchAnchor::None`. A live
orchestrator session that merely references an undispatched task classifies as
not-active. `run_state`/`confidence` reporting for recent/stale and preview text
is unchanged.
and reduces exact structured events into `EntityActivity`; `domain/mod.rs`
owns the three visible states (`Idle`, `Running`, and `HumanGate`) plus the
typed `Worker`/`FirstOfficer` handler carried only by `Running`. Detection
requires canonical dispatch/session correlation and structured start, stop,
FO-action, or approve/reject gate records. Process presence, mtimes, generic
path mentions, and filesystem writes alone must remain idle. JSONL scanning
must retain projected summaries behind per-file byte cursors rather than drop
or reread large unchanged artifacts. Codex child evidence requires a non-empty
parent thread, code-mode activity uses only nested executable commands, and
Claude worker lifecycle evidence stays scoped to its exact parent session and
dispatch call.
- `crates/spacetop-core/src/editor.rs` owns opening selected files in an
external editor/viewer path; it must not become a workflow-state writer
without explicit policy change.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ curl -fsSL https://github.com/spacedock-dev/spacetop/releases/latest/download/in

Spacetop is an active read-first TUI. It can discover workflows, open an explicit
workflow directory, parse active and archived work items, preview markdown,
render workflow graphs, show selected worktree state, mark active tasks that
match a running local Codex or Claude Code session, open query-backed search,
render workflow graphs, show selected worktree state, derive entity activity
from structured local Codex and Claude Code events, open query-backed search,
timeline, metrics, activity, and relation views, auto-refresh filesystem
changes, read YAML user config, restore per-workflow session state, expose
headless query/export commands, and explicitly sync with `git pull --ff-only`.
Expand All @@ -55,9 +55,12 @@ rather than failing.

The product contract remains read-only by default: Spacedock markdown files are
the source of truth, and state-changing features must be explicit and auditable.
The active-session marker (`●`) is local inferred evidence for running
medium-or-better local session matches only; it is not an authoritative
assignment, lock, waiting, or stuck-state signal.
Entity activity has three values: `idle`, `running`, and `human-gate`.
`running` identifies its handler as `running · worker` or `running · FO`;
`human-gate` uses the high-salience `◆` list marker. Detection fails closed:
only exact structured worker, first-officer, terminal, and approve/reject gate
records create activity. Process names, mtimes, workflow stages, ordinary path
mentions, and filesystem writes alone do not claim a handler.

## Headless CLI

Expand Down
1 change: 1 addition & 0 deletions crates/spacetop-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition.workspace = true
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
notify = "8"
walkdir = "2"
Expand Down
169 changes: 75 additions & 94 deletions crates/spacetop-core/src/domain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,13 @@ pub struct Entity {
pub main_body: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AgentKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum AgentRuntime {
Codex,
ClaudeCode,
}

impl AgentKind {
impl AgentRuntime {
pub fn label(self) -> &'static str {
match self {
Self::Codex => "Codex",
Expand All @@ -219,143 +219,124 @@ impl AgentKind {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum AttributionConfidence {
Low,
Medium,
High,
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ActivityHandler {
Worker,
FirstOfficer,
}

impl AttributionConfidence {
impl ActivityHandler {
pub fn label(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Worker => "worker",
Self::FirstOfficer => "FO",
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum AgentSessionState {
Stale,
Recent,
pub enum EntityActivityStatus {
Idle,
Running,
HumanGate,
}

impl AgentSessionState {
impl EntityActivityStatus {
pub fn label(self) -> &'static str {
match self {
Self::Stale => "stale",
Self::Recent => "recent",
Self::Idle => "idle",
Self::Running => "running",
Self::HumanGate => "human-gate",
}
}
}

/// Whether a matched session is anchored to a *dispatched worker* for this
/// entity, as opposed to merely mentioning the entity in passing. Only an
/// anchored session may drive the active marker: a live orchestrator session
/// that references an undispatched task's file path is `None` and must not
/// light up as running.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DispatchAnchor {
/// The session matched via the entity's own worktree path (High-confidence
/// ownership): a worker running in the entity's isolated worktree.
OwnedWorktree,
/// The session carries a positive
/// `/tmp/spacedock-dispatch/spacedock-ensign-<slug>-<stage>.md` marker for
/// this entity's slug: explicit dispatch evidence.
DispatchedForEntity,
/// No dispatch/ownership evidence — a bare path/id mention. Not eligible to
/// drive the active marker on its own.
None,
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntityActivity {
Idle {
updated_unix: Option<i64>,
},
Running {
handler: ActivityHandler,
runtime: AgentRuntime,
session_id: String,
updated_unix: i64,
},
HumanGate {
runtime: AgentRuntime,
session_id: String,
updated_unix: i64,
},
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentSessionEvidence {
pub agent: AgentKind,
pub session_id: String,
pub display_name: Option<String>,
pub confidence: AttributionConfidence,
pub liveness: AgentSessionLiveness,
pub latest_activity_unix: Option<i64>,
pub matched_worktree: Option<PathBuf>,
pub dispatch_anchor: DispatchAnchor,
impl Default for EntityActivity {
fn default() -> Self {
Self::Idle { updated_unix: None }
}
}

impl AgentSessionEvidence {
pub fn run_state(&self) -> AgentSessionState {
self.liveness.run_state()
impl EntityActivity {
pub fn status(&self) -> EntityActivityStatus {
match self {
Self::Idle { .. } => EntityActivityStatus::Idle,
Self::Running { .. } => EntityActivityStatus::Running,
Self::HumanGate { .. } => EntityActivityStatus::HumanGate,
}
}

pub fn is_active_marker(&self) -> bool {
self.run_state() == AgentSessionState::Running
&& self.dispatch_anchor != DispatchAnchor::None
pub fn status_label(&self) -> &'static str {
match self {
Self::Idle { .. } => "idle",
Self::Running {
handler: ActivityHandler::Worker,
..
} => "running · worker",
Self::Running {
handler: ActivityHandler::FirstOfficer,
..
} => "running · FO",
Self::HumanGate { .. } => "human-gate",
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentSessionLiveness {
LivePid { pid: u32 },
LiveResumeCommand { session_key: String },
ObservedSessionWrite { until_unix: i64 },
RecentMtime,
Stale,
}
pub fn runtime(&self) -> Option<AgentRuntime> {
match self {
Self::Running { runtime, .. } | Self::HumanGate { runtime, .. } => Some(*runtime),
Self::Idle { .. } => None,
}
}

impl AgentSessionLiveness {
pub fn run_state(&self) -> AgentSessionState {
pub fn session_id(&self) -> Option<&str> {
match self {
Self::LivePid { .. }
| Self::LiveResumeCommand { .. }
| Self::ObservedSessionWrite { .. } => AgentSessionState::Running,
Self::RecentMtime => AgentSessionState::Recent,
Self::Stale => AgentSessionState::Stale,
Self::Running { session_id, .. } | Self::HumanGate { session_id, .. } => {
Some(session_id)
}
Self::Idle { .. } => None,
}
}

pub fn label(&self) -> &'static str {
pub fn updated_unix(&self) -> Option<i64> {
match self {
Self::LivePid { .. } => "pid",
Self::LiveResumeCommand { .. } => "resume",
Self::ObservedSessionWrite { .. } => "write",
Self::RecentMtime => "mtime",
Self::Stale => "stale",
Self::Idle { updated_unix } => *updated_unix,
Self::Running { updated_unix, .. } | Self::HumanGate { updated_unix, .. } => {
Some(*updated_unix)
}
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntitySessionAttribution {
pub struct EntityActivityAttribution {
pub entity_id: String,
pub evidence: Vec<AgentSessionEvidence>,
}

impl EntitySessionAttribution {
pub fn best_evidence(&self) -> Option<&AgentSessionEvidence> {
self.evidence.iter().max_by_key(|evidence| {
(
evidence.run_state(),
evidence.confidence,
evidence.latest_activity_unix,
)
})
}

pub fn has_active_marker(&self) -> bool {
self.evidence
.iter()
.any(AgentSessionEvidence::is_active_marker)
}
pub activity: EntityActivity,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionScanReport {
pub workflow_dir: PathBuf,
pub repo_root: PathBuf,
pub scanned_roots: Vec<PathBuf>,
pub attributions: Vec<EntitySessionAttribution>,
pub attributions: Vec<EntityActivityAttribution>,
pub errors: Vec<String>,
}

Expand Down
Loading
Loading