feat(kei-registry): status subcommand — cross-cutting substrate dashboard

Phase 3 of substrate-unified-registry: a single command shows every
live artefact across the three sources without merging stores.

`kei-registry status` joins:
1. `blocks` table (kei-registry SQLite) — active counts per BlockType,
   plus the registered path-atoms with DNA prefix + body sha8.
2. `git for-each-ref refs/heads` (shell-out, no DB persistence) — local
   branches, current marker, ahead/behind via `upstream:track,nobracket`.
3. `agents` table (kei-ledger SQLite) — fork counts per status
   (running/done/failed/merged/rejected). Missing ledger DB → section
   skipped, never an error.

Output: ASCII multi-section table by default; `--format json` for
machine consumption.

Files:
- `_primitives/_rust/kei-registry/src/status.rs` — new module, ~270
  LOC. Pure read-side per Constructor Pattern. 7 unit tests cover
  `parse_track` (in sync / ahead / behind / both / "gone"), DNA prefix
  rendering, and empty-status section presence.
- `_primitives/_rust/kei-registry/src/cli.rs` — new `Status` variant
  with `--db`, `--git-repo`, `--ledger-db`, `--format` flags.
- `_primitives/_rust/kei-registry/src/handlers.rs` — `handle_status`
  dispatcher, ASCII/JSON branching.
- `_primitives/_rust/kei-registry/src/lib.rs` — module export.

End-to-end run from kit root shows the prior gap: 17 local branches
(many `worktree-agent-*` orphans), kei-ledger summary 4 running /
158 done / 35 failed / 7 merged / 0 rejected — visibility the user
asked for ("в каждой сессии видеть, чтобы не бегать по диску в
поисках несмерженных").

What this does NOT do (Phase 4):
- No orphan detection (`kei-status orphans`) — counts only.
- No auto-registration of branches into kei-ledger (Phase 2). Branches
  come from live `git for-each-ref` shell-out; if the repo moves or
  is deleted the row vanishes from the dashboard. Acceptable for v1.

=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
  - Phase 2 (post-checkout hook → kei-ledger auto-register)
  - Phase 4 (orphan detection: branches with no commits in N days,
    path-atoms with no consumers, agent forks stuck running)
  - --filter flags (--type, --status) for targeted queries

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-05-01 22:46:53 +08:00
parent 3422bdc8c3
commit d6dbdee870
4 changed files with 392 additions and 0 deletions

View file

@ -147,4 +147,25 @@ pub enum Command {
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
dry_run: bool, dry_run: bool,
}, },
/// Cross-cutting status dashboard: blocks per type, registered path
/// atoms, local git branches with ahead/behind, and agent forks from
/// `kei-ledger` (if present).
Status {
/// Registry SQLite path (default: `$KEI_REGISTRY_DB` or
/// `~/.claude/registry.sqlite`).
#[arg(long)]
db: Option<PathBuf>,
/// Local git repo to scan for branches (default: current dir).
#[arg(long, default_value = ".")]
git_repo: PathBuf,
/// kei-ledger SQLite path (default: `$KEI_LEDGER_DB` or
/// `~/.claude/agents/ledger.sqlite`). Missing file → agent
/// section is skipped, never an error.
#[arg(long)]
ledger_db: Option<PathBuf>,
/// Output format: `ascii` (default) or `json`.
#[arg(long, default_value = "ascii")]
format: String,
},
} }

View file

@ -68,9 +68,37 @@ pub fn dispatch(cmd: Command) -> Result<Outcome> {
Command::IndexSubstrate { kit_root, db, dry_run } => { Command::IndexSubstrate { kit_root, db, dry_run } => {
index_substrate::handle_index_substrate(Some(kit_root), db, dry_run) index_substrate::handle_index_substrate(Some(kit_root), db, dry_run)
} }
Command::Status {
db,
git_repo,
ledger_db,
format,
} => handle_status(db, git_repo, ledger_db, format),
} }
} }
fn handle_status(
db: Option<PathBuf>,
git_repo: PathBuf,
ledger_db: Option<PathBuf>,
format: String,
) -> Result<Outcome> {
let db_path = resolve_db(db);
let conn = open_db(&db_path)?;
let ledger = ledger_db.unwrap_or_else(crate::status::default_ledger_path);
let snap = crate::status::compute_status(&conn, Some(&git_repo), Some(&ledger))?;
match format.as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&snap)?);
}
"ascii" | "" => {
print!("{}", crate::status::render_ascii(&snap));
}
other => anyhow::bail!("unknown --format '{other}' (use ascii or json)"),
}
Ok(Outcome::Ok)
}
fn handle_init(db: Option<PathBuf>) -> Result<Outcome> { fn handle_init(db: Option<PathBuf>) -> Result<Outcome> {
let path = resolve_db(db); let path = resolve_db(db);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {

View file

@ -27,6 +27,7 @@ pub mod related;
pub mod scan_orchestrator; pub mod scan_orchestrator;
pub mod scanners; pub mod scanners;
pub mod stats; pub mod stats;
pub mod status;
pub mod store; pub mod store;
pub use block::{Block, BlockType}; pub use block::{Block, BlockType};
@ -35,4 +36,5 @@ pub use dna_block::{compose_for_block, compose_for_block_with_nonce};
pub use registry::{find_by_path, get, list, list_by_type, mark_superseded, register}; pub use registry::{find_by_path, get, list, list_by_type, mark_superseded, register};
pub use related::{find_related, RelatedHit}; pub use related::{find_related, RelatedHit};
pub use stats::{compute_stats, Stats}; pub use stats::{compute_stats, Stats};
pub use status::{compute_status, render_ascii, Status};
pub use store::{open_db, SCHEMA_VERSION}; pub use store::{open_db, SCHEMA_VERSION};

View file

@ -0,0 +1,341 @@
//! Cross-cutting "what is alive right now" view.
//!
//! Constructor Pattern: pure read-side cube. Joins three sources for a
//! single dashboard:
//! 1. `blocks` table from kei-registry — atoms, skills, rules, hooks,
//! primitives, plus path-atoms (atoms whose source file is
//! `_blocks/path-*.md`).
//! 2. `agents` table from `~/.claude/agents/ledger.sqlite` if present —
//! agent forks per RULE 0.12, with status (running / done / failed /
//! merged / rejected).
//! 3. `git for-each-ref refs/heads` shell-out — local branches with
//! `ahead`, `behind` and `dirty` flags relative to their upstream.
//!
//! No I/O beyond DB reads + one git invocation. No writes. The handler
//! formats the gathered struct into either an ASCII table (default) or
//! JSON (`--format json`).
use anyhow::{Context, Result};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::block::BlockType;
/// Aggregate snapshot returned by `compute_status`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Status {
pub blocks_by_type: BTreeMap<String, u64>,
pub path_atoms: Vec<PathAtomRow>,
pub branches: Vec<BranchRow>,
pub agents: Option<AgentSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathAtomRow {
pub name: String,
pub dna_prefix: String,
pub body_sha8: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchRow {
pub name: String,
pub current: bool,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub last_commit: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AgentSummary {
pub running: u64,
pub done: u64,
pub failed: u64,
pub merged: u64,
pub rejected: u64,
}
/// Compute the full status snapshot. `git_repo` is the path to scan for
/// branches (typically the current working directory). `ledger_db` is
/// the optional path to `~/.claude/agents/ledger.sqlite`; if it doesn't
/// exist, `agents` field is `None`.
pub fn compute_status(
conn: &Connection,
git_repo: Option<&Path>,
ledger_db: Option<&Path>,
) -> Result<Status> {
let mut s = Status::default();
s.blocks_by_type = block_counts(conn)?;
s.path_atoms = path_atom_rows(conn)?;
if let Some(repo) = git_repo {
s.branches = git_branches(repo).unwrap_or_default();
}
if let Some(db) = ledger_db {
if db.exists() {
s.agents = ledger_agent_summary(db).ok();
}
}
Ok(s)
}
fn block_counts(conn: &Connection) -> Result<BTreeMap<String, u64>> {
let mut out = BTreeMap::new();
for bt in BlockType::all() {
let mut stmt = conn
.prepare(
"SELECT COUNT(*) FROM blocks \
WHERE block_type = ?1 AND superseded_by IS NULL",
)
.context("prepare block_counts")?;
let n: i64 = stmt
.query_row(rusqlite::params![bt.as_str()], |r| r.get(0))
.context("query block_counts")?;
out.insert(bt.as_str().to_string(), n as u64);
}
Ok(out)
}
fn path_atom_rows(conn: &Connection) -> Result<Vec<PathAtomRow>> {
// Convention: path-atoms are atoms whose source file matches
// `_blocks/path-<name>.md`. SQL LIKE keeps it server-side; the
// resulting rows are sorted by name for stable output.
let mut stmt = conn
.prepare(
"SELECT name, dna, body_sha FROM blocks \
WHERE block_type = 'atom' \
AND superseded_by IS NULL \
AND path LIKE '%/_blocks/path-%.md' \
ORDER BY name",
)
.context("prepare path_atom_rows")?;
let rows = stmt
.query_map([], |r| {
let dna: String = r.get(1)?;
let dna_prefix = dna_prefix(&dna);
Ok(PathAtomRow {
name: r.get(0)?,
dna_prefix,
body_sha8: r.get(2)?,
})
})?
.filter_map(Result::ok)
.collect();
Ok(rows)
}
/// Take the first three segments of a `<role>::<caps>::<scope_sha8>::...`
/// DNA so the displayed prefix is readable but identifying.
fn dna_prefix(dna: &str) -> String {
let mut parts = dna.split("::").take(3).collect::<Vec<_>>();
if parts.len() < 3 {
return dna.to_string();
}
parts.push("");
parts.join("::")
}
fn git_branches(repo: &Path) -> Result<Vec<BranchRow>> {
let current_branch = run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"]).ok();
let out = run_git(
repo,
&[
"for-each-ref",
"--format=%(refname:short)\t%(upstream:short)\t%(upstream:track,nobracket)\t%(objectname:short)",
"refs/heads",
],
)?;
let mut rows = Vec::new();
for line in out.lines() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 4 {
continue;
}
let name = parts[0].to_string();
let upstream = if parts[1].is_empty() {
None
} else {
Some(parts[1].to_string())
};
let (ahead, behind) = parse_track(parts[2]);
rows.push(BranchRow {
current: current_branch.as_deref() == Some(&name),
name,
upstream,
ahead,
behind,
last_commit: parts[3].to_string(),
});
}
Ok(rows)
}
/// Parse `upstream:track,nobracket` output. Examples:
/// `""` (in sync), `"ahead 3"`, `"behind 1"`, `"ahead 3, behind 1"`,
/// `"gone"` (upstream deleted).
fn parse_track(s: &str) -> (u32, u32) {
let mut ahead = 0u32;
let mut behind = 0u32;
for part in s.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
ahead = n.parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
behind = n.parse().unwrap_or(0);
}
}
(ahead, behind)
}
fn run_git(repo: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.context("spawn git")?;
if !out.status.success() {
anyhow::bail!(
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
}
fn ledger_agent_summary(db: &Path) -> Result<AgentSummary> {
let conn = rusqlite::Connection::open_with_flags(
db,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.context("open ledger DB read-only")?;
let mut s = AgentSummary::default();
for (status, slot) in [
("running", &mut s.running),
("done", &mut s.done),
("failed", &mut s.failed),
("merged", &mut s.merged),
("rejected", &mut s.rejected),
] {
let mut stmt = conn.prepare("SELECT COUNT(*) FROM agents WHERE status = ?1")?;
let n: i64 = stmt.query_row(rusqlite::params![status], |r| r.get(0))?;
*slot = n as u64;
}
Ok(s)
}
/// Render `Status` as a multi-section ASCII report.
pub fn render_ascii(s: &Status) -> String {
let mut out = String::new();
out.push_str("=== Substrate Status ===\n\n");
out.push_str("[Blocks — active count by type]\n");
for (k, v) in &s.blocks_by_type {
out.push_str(&format!(" {:<14} {}\n", k, v));
}
out.push('\n');
out.push_str(&format!("[Path Atoms — {}]\n", s.path_atoms.len()));
for p in &s.path_atoms {
out.push_str(&format!(
" {:<14} {:<28} body:{}\n",
p.name, p.dna_prefix, p.body_sha8
));
}
if s.path_atoms.is_empty() {
out.push_str(" (none registered)\n");
}
out.push('\n');
out.push_str(&format!("[Local Branches — {}]\n", s.branches.len()));
for b in &s.branches {
let marker = if b.current { "*" } else { " " };
let track = match (b.ahead, b.behind) {
(0, 0) => "in sync".to_string(),
(a, 0) => format!("ahead {a}"),
(0, b_) => format!("behind {b_}"),
(a, b_) => format!("ahead {a}, behind {b_}"),
};
let upstream = b.upstream.as_deref().unwrap_or("(none)");
out.push_str(&format!(
" {} {:<40} → {:<25} {} @ {}\n",
marker, b.name, upstream, track, b.last_commit
));
}
out.push('\n');
if let Some(a) = &s.agents {
out.push_str("[Agent Forks — kei-ledger]\n");
out.push_str(&format!(
" running:{} done:{} merged:{} failed:{} rejected:{}\n",
a.running, a.done, a.merged, a.failed, a.rejected
));
} else {
out.push_str("[Agent Forks]\n (no kei-ledger DB found)\n");
}
out.push('\n');
out
}
/// Default ledger path: `$KEI_LEDGER_DB` or `~/.claude/agents/ledger.sqlite`.
pub fn default_ledger_path() -> PathBuf {
if let Some(v) = std::env::var_os("KEI_LEDGER_DB") {
return PathBuf::from(v);
}
let home = std::env::var_os("HOME").unwrap_or_default();
PathBuf::from(home).join(".claude/agents/ledger.sqlite")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_track_in_sync() {
assert_eq!(parse_track(""), (0, 0));
}
#[test]
fn parse_track_ahead() {
assert_eq!(parse_track("ahead 3"), (3, 0));
}
#[test]
fn parse_track_behind() {
assert_eq!(parse_track("behind 7"), (0, 7));
}
#[test]
fn parse_track_both() {
assert_eq!(parse_track("ahead 3, behind 1"), (3, 1));
}
#[test]
fn parse_track_gone_treated_as_zero() {
// Upstream deleted — git emits "gone"; we don't surface it as
// ahead/behind, so callers see (0, 0). Acceptable for a status
// dashboard; a future field could carry it explicitly.
assert_eq!(parse_track("gone"), (0, 0));
}
#[test]
fn dna_prefix_three_segments() {
let dna = "atom::md::1a771d51::b8f9e85f-abc12345";
assert_eq!(dna_prefix(dna), "atom::md::1a771d51::…");
}
#[test]
fn render_ascii_empty_status_has_all_sections() {
let s = Status::default();
let out = render_ascii(&s);
assert!(out.contains("Blocks"));
assert!(out.contains("Path Atoms"));
assert!(out.contains("Local Branches"));
assert!(out.contains("Agent Forks"));
assert!(out.contains("(none registered)"));
assert!(out.contains("(no kei-ledger DB found)"));
}
}