feat(graph): live runtime DNA viewer — kei-graph-export + lbm-graph-viz adapter

User pushback: "можно нашего Кейси подключить к обсидиан? будет в
онлайне строить граф из всех наших агентов?"

Closer-to-question architecture: don't build new Obsidian plugin —
re-use the legacy `~/Projects/lbm-graph-viz/` D3 viewer (lineage:
keicode → living-graph → lbm → lbm-graph-viz → keisei-graph). Strip
its Hebbian/co-change edges, replace with DNA-derived edges from the
kei-registry + kei-ledger. Open in any browser, file://...index.html.

NEW Rust crate `_primitives/_rust/kei-graph-export/` (~440 LOC, 5 files)

Reads:
  ~/.claude/registry.sqlite       (730 active blocks)
  ~/.claude/agents/ledger.sqlite  (6 agents post-cleanup)
  _manifests/*.toml               (38 agent manifests)

Emits 581-node, 291-edge graph. Edge types:
  block_dep        171  manifest → atom (blocks=[])
  path_ref          99  manifest → atom (path:NAME refs)
  branch_lineage    11  parent_branch → branch
  agent_uses_manifest 10  agent → manifest (slug from branch name)

Output formats:
  --format spaces-fragment  →  `window.RUNTIME_SPACE = {...}` JS file
  --format json             →  raw {nodes, links} for downstream tools

Block-name lookup is multi-resolution: each block is registered under
display name + lowercased + file-stem slug (from path basename) so
manifest references like `blocks = ["baseline"]` resolve to a registry
row whose `name` column holds "BASELINE — inherit from Main Claude".
Without this fix the graph had 0 block_dep edges; with it, 171.

NEW background updater `hooks/graph-export-watcher.sh` + launchd plist
template `_primitives/templates/io.keisei.graph-export.plist`

5-second loop:
  while true; do
      kei-graph-export --format spaces-fragment --output <viz>/data-runtime.js.tmp
      mv <viz>/data-runtime.js.tmp <viz>/data-runtime.js  # atomic
      sleep 5
  done

launchd plist substitutes `HOME_DIR` and `HOOKS_DIR` placeholders at
install time. RunAtLoad=true, KeepAlive=true. Logs to
~/.claude/memory/graph-export.log. Bypass: GRAPH_EXPORT_BYPASS=1.

Loaded into user-side launchd (PID 16474 confirmed running). File
mtime advances every 5s — live updates verified.

PATCH `~/Projects/lbm-graph-viz/index.html` (outside kit, surgical)

Three changes:
  1. Add `<script src="data-runtime.js">` BEFORE `spaces.js` (window
     global available when SPACES is defined).
  2. After spaces.js: `if (window.RUNTIME_SPACE) SPACES.runtime = window.RUNTIME_SPACE;`
  3. Auto-refresh setInterval(5s): fetch data-runtime.js, eval (re-
     assigns window.RUNTIME_SPACE), hash-compare, re-render via
     `rebuildGraph()` if currently viewing the runtime space.

window.RUNTIME_SPACE (not const RUNTIME_SPACE) avoids the
"const cannot be re-declared" error on subsequent eval() calls.

Effect: open file://~/Projects/lbm-graph-viz/index.html in any
browser, switch to "Runtime" space — full DNA graph of every agent /
atom / skill / branch / manifest / hook / primitive / rule, force-
laid-out by D3. Updates every 5 seconds without page reload.

What this does NOT do (deferred):
  - Obsidian mirror — separate work, would emit .md per node into
    ~/Projects/KeiSeiVault/. Useful for backlinks navigation but
    file-watcher latency similar to current 5s polling.
  - Skill-invocation edges — table is empty until next Skill tool
    use; will populate naturally.
  - Scoped queries (orphan finder, hot-path PageRank). Out of scope
    for v1; the JSON --format export feeds any downstream tool.
  - `agent_uses_manifest` heuristic warns on unknown subagent slugs
    (e.g. `physics-deriver` with no manifest yet). Non-fatal.

=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
  - Obsidian vault mirror (Phase C, separate work)
  - Skill-edges populate from real Skill use (not blockered)
  - Hot-path PageRank highlighting in viewer (cosmetic)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-05-02 13:07:21 +08:00
parent 0cf823413e
commit a31a056f61
11 changed files with 644 additions and 6 deletions

View file

@ -3663,6 +3663,18 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "kei-graph-export"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"rusqlite",
"serde",
"serde_json",
"toml",
]
[[package]] [[package]]
name = "kei-hibernate" name = "kei-hibernate"
version = "0.1.0" version = "0.1.0"

View file

@ -177,6 +177,8 @@ members = [
"kei-mcp", "kei-mcp",
# SQL ↔ TypeScript schema drift detector # SQL ↔ TypeScript schema drift detector
"kei-db-contract", "kei-db-contract",
# Live runtime-graph exporter (registry + ledger → D3 space fragment)
"kei-graph-export",
] ]
[workspace.package] [workspace.package]

View file

@ -0,0 +1,18 @@
[package]
name = "kei-graph-export"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
description = "Export KeiSei registry + ledger as D3 graph space fragment"
[[bin]]
name = "kei-graph-export"
path = "src/main.rs"
[dependencies]
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true }
toml = { workspace = true }

View file

@ -0,0 +1,140 @@
use crate::nodes::{AgentInfo, ManifestInfo};
use crate::types::{sanitize_id, Edge};
use anyhow::Result;
use rusqlite::Connection;
use std::collections::HashMap;
// Edge rule 1: manifest → block
pub fn edges_manifest_block(
manifests: &[ManifestInfo],
lookup: &HashMap<String, String>,
links: &mut Vec<Edge>,
) {
for m in manifests {
for name in &m.blocks {
if let Some(id) = lookup.get(name.as_str()) {
links.push(edge(&m.node_id, id, "block_dep", 1.0));
}
}
}
}
// Edge rule 2: manifest → path-atom
pub fn edges_manifest_path_ref(
manifests: &[ManifestInfo],
lookup: &HashMap<String, String>,
links: &mut Vec<Edge>,
) {
for m in manifests {
for name in &m.path_refs {
if let Some(id) = lookup.get(name.as_str()) {
links.push(edge(&m.node_id, id, "path_ref", 0.5));
}
}
}
}
// Edge rule 3: manifest → rule block
pub fn edges_manifest_rule(
manifests: &[ManifestInfo],
lookup: &HashMap<String, String>,
links: &mut Vec<Edge>,
) {
for m in manifests {
for name in &m.rule_blocks {
if let Some(id) = lookup.get(name.as_str()) {
links.push(edge(&m.node_id, id, "rule_dep", 0.7));
}
}
}
}
// Edge rule 4: branch lineage
pub fn edges_branch_lineage(led: &Connection, links: &mut Vec<Edge>) -> Result<()> {
let mut stmt = led.prepare(
"SELECT branch, parent_branch FROM agents \
WHERE branch IS NOT NULL AND parent_branch IS NOT NULL AND parent_branch != ''",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?;
for row in rows {
let (branch, parent) = row?;
links.push(edge(
&format!("branch::{}", sanitize_id(&parent)),
&format!("branch::{}", sanitize_id(&branch)),
"branch_lineage", 1.0,
));
}
Ok(())
}
// Edge rule 5: agent fork
pub fn edges_agent_fork(agents: &[AgentInfo], links: &mut Vec<Edge>) {
for a in agents {
if let Some(pid) = &a.fork_parent_id {
links.push(edge(&sanitize_id(pid), &a.node_id, "agent_fork", 1.0));
}
}
}
// Edge rule 6: skill invocation
pub fn edges_skill_invocation(led: &Connection, links: &mut Vec<Edge>) -> Result<()> {
let mut stmt = led.prepare(
"SELECT agent_id, skill_name FROM skill_invocations \
WHERE agent_id IS NOT NULL AND skill_name IS NOT NULL",
)?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?;
for row in rows {
let (agent_id, skill_name) = row?;
links.push(edge(
&sanitize_id(&agent_id),
&format!("skill::{}", sanitize_id(&skill_name)),
"skill_run", 0.5,
));
}
Ok(())
}
// Edge rule 7: agent → manifest (heuristic)
pub fn edges_agent_manifest(
agents: &[AgentInfo],
manifests: &[ManifestInfo],
links: &mut Vec<Edge>,
) {
for a in agents {
let slug = subagent_slug(&a.branch);
if let Some(m) = manifests.iter().find(|m| m.name == slug) {
links.push(edge(&a.node_id, &m.node_id, "agent_uses_manifest", 1.0));
} else if !slug.is_empty() {
eprintln!("warn: no manifest for slug '{}' (branch: {})", slug, a.branch);
}
}
}
fn subagent_slug(branch: &str) -> String {
let part = branch.split('/').last().unwrap_or(branch);
let stripped = strip_trailing_digits_and_dashes(part);
let stripped = stripped.strip_prefix("inline-").unwrap_or(stripped);
stripped.to_string()
}
fn strip_trailing_digits_and_dashes(s: &str) -> &str {
let mut end = s.len();
let bytes = s.as_bytes();
while end > 0 && (bytes[end - 1].is_ascii_digit() || bytes[end - 1] == b'-') {
end -= 1;
}
s[..end].trim_end_matches('-')
}
fn edge(src: &str, tgt: &str, kind: &str, weight: f32) -> Edge {
Edge {
source: src.to_string(),
target: tgt.to_string(),
kind: kind.to_string(),
weight,
}
}

View file

@ -0,0 +1,69 @@
use crate::{edges, nodes};
use crate::types::{Edge, Node, Space, SpaceData};
use anyhow::Result;
use rusqlite::Connection;
use std::collections::HashMap;
use std::path::PathBuf;
pub struct GraphConfig {
pub registry_db: PathBuf,
pub ledger_db: PathBuf,
pub manifests_dir: PathBuf,
}
pub fn build_graph(cfg: &GraphConfig) -> Result<Space> {
let reg = Connection::open(&cfg.registry_db)?;
let led = Connection::open(&cfg.ledger_db)?;
let (mut nodes, block_lookup) = nodes::collect_blocks(&reg)?;
let agents = nodes::collect_agents(&led, &mut nodes)?;
let manifests = nodes::collect_manifests(&cfg.manifests_dir, &mut nodes)?;
nodes::collect_branches(&led, &mut nodes)?;
nodes::collect_skills(&led, &mut nodes)?;
let mut links = Vec::new();
edges::edges_manifest_block(&manifests, &block_lookup, &mut links);
edges::edges_manifest_path_ref(&manifests, &block_lookup, &mut links);
edges::edges_manifest_rule(&manifests, &block_lookup, &mut links);
edges::edges_branch_lineage(&led, &mut links)?;
edges::edges_agent_fork(&agents, &mut links);
edges::edges_skill_invocation(&led, &mut links)?;
edges::edges_agent_manifest(&agents, &manifests, &mut links);
compute_connections(&mut nodes, &links);
Ok(Space {
name: "Runtime",
icon: "activity",
description: "Live KeiSei agent + atom DNA graph",
colors: default_colors(),
data: SpaceData { nodes, links },
})
}
fn compute_connections(nodes: &mut Vec<Node>, links: &[Edge]) {
let mut degree: HashMap<String, usize> = HashMap::new();
for l in links {
*degree.entry(l.source.clone()).or_default() += 1;
*degree.entry(l.target.clone()).or_default() += 1;
}
for n in nodes.iter_mut() {
n.connections = *degree.get(&n.id).unwrap_or(&0);
}
}
fn default_colors() -> HashMap<String, String> {
[
("atom", "#6ee7b7"),
("hook", "#f59e0b"),
("primitive", "#60a5fa"),
("rule", "#c084fc"),
("skill", "#34d399"),
("agent", "#f87171"),
("branch", "#94a3b8"),
("manifest", "#fb923c"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}

View file

@ -0,0 +1,66 @@
mod types;
mod nodes;
mod edges;
mod graph;
use anyhow::Result;
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "kei-graph-export", about = "Export KeiSei graph as D3 space fragment")]
struct Cli {
#[arg(long)]
registry_db: Option<PathBuf>,
#[arg(long)]
ledger_db: Option<PathBuf>,
#[arg(long)]
manifests_dir: Option<PathBuf>,
#[arg(long, default_value = "spaces-fragment")]
format: String,
#[arg(long, short = 'o')]
output: Option<PathBuf>,
}
fn home_path(suffix: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(suffix)
}
fn main() -> Result<()> {
let cli = Cli::parse();
let registry_db = cli.registry_db
.or_else(|| std::env::var("KEI_REGISTRY_DB").ok().map(PathBuf::from))
.unwrap_or_else(|| home_path(".claude/registry.sqlite"));
let ledger_db = cli.ledger_db
.or_else(|| std::env::var("KEI_LEDGER_DB").ok().map(PathBuf::from))
.unwrap_or_else(|| home_path(".claude/agents/ledger.sqlite"));
let manifests_dir = cli.manifests_dir
.unwrap_or_else(|| home_path("Projects/KeiSeiKit-public/_manifests"));
let cfg = graph::GraphConfig { registry_db, ledger_db, manifests_dir };
let space = graph::build_graph(&cfg)?;
let content = match cli.format.as_str() {
"json" => serde_json::to_string_pretty(&space.data)?,
// window.RUNTIME_SPACE = ... — assigning to global avoids the
// `const RUNTIME_SPACE` re-declaration error when the auto-refresh
// script re-evaluates this file every 5s in the browser.
_ => format!(
"// Auto-generated by kei-graph-export — do not edit.\n\
window.RUNTIME_SPACE = {};\n",
serde_json::to_string(&space)?
),
};
match cli.output {
Some(path) => std::fs::write(path, content)?,
None => print!("{}", content),
}
Ok(())
}

View file

@ -0,0 +1,218 @@
use crate::types::{dna_prefix, sanitize_id, truncate_chars, Node};
use anyhow::Result;
use rusqlite::Connection;
use std::collections::HashMap;
use std::path::PathBuf;
pub struct ManifestInfo {
pub node_id: String,
pub name: String,
pub blocks: Vec<String>,
pub path_refs: Vec<String>,
pub rule_blocks: Vec<String>,
}
pub struct AgentInfo {
pub node_id: String,
pub branch: String,
pub fork_parent_id: Option<String>,
}
pub fn collect_blocks(reg: &Connection) -> Result<(Vec<Node>, HashMap<String, String>)> {
// Pull `path` too so we can derive the file-stem slug (e.g.
// `_blocks/baseline.md` → `baseline`). Manifests reference blocks by
// this short slug, while the `name` column stores display names like
// "BASELINE — inherit from Main Claude". The lookup must accept both.
let mut stmt = reg.prepare(
"SELECT dna, block_type, name, path FROM blocks WHERE superseded_by IS NULL",
)?;
let mut nodes = Vec::new();
let mut lookup: HashMap<String, String> = HashMap::new();
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
))
})?;
for row in rows {
let (dna, btype, name, path) = row?;
let id = dna_prefix(&dna);
// Primary lookup: full display name (e.g. used by some manifests).
lookup.insert(name.clone(), id.clone());
// Secondary lookup: lowercased name (case-insensitive resolve).
lookup.insert(name.to_lowercase(), id.clone());
// Tertiary lookup: file-stem slug — the canonical reference form
// in `_manifests/*.toml` blocks=[] / rule_blocks=[].
if let Some(stem) = std::path::Path::new(&path)
.file_stem()
.and_then(|s| s.to_str())
{
lookup.insert(stem.to_string(), id.clone());
lookup.insert(stem.to_lowercase(), id.clone());
}
nodes.push(Node {
id,
title: format!("{}: {}", btype, truncate_chars(&name, 60)),
kind: btype.clone(),
category: btype,
tags: vec![],
connections: 0,
extra: HashMap::new(),
});
}
Ok((nodes, lookup))
}
type AgentRow = (String, Option<String>, Option<String>, Option<String>,
Option<String>, Option<String>, Option<i64>, Option<i64>,
Option<i64>, Option<String>);
pub fn collect_agents(led: &Connection, nodes: &mut Vec<Node>) -> Result<Vec<AgentInfo>> {
let mut stmt = led.prepare(
"SELECT id, branch, parent_branch, model, outcome, status, \
cost_micro_cents, tokens_in, tokens_out, fork_parent_id FROM agents",
)?;
let mut agents = Vec::new();
let rows = stmt.query_map([], |r| Ok((
r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?,
r.get::<_, Option<String>>(2)?, r.get::<_, Option<String>>(3)?,
r.get::<_, Option<String>>(4)?, r.get::<_, Option<String>>(5)?,
r.get::<_, Option<i64>>(6)?, r.get::<_, Option<i64>>(7)?,
r.get::<_, Option<i64>>(8)?, r.get::<_, Option<String>>(9)?,
)))?;
for row in rows {
let info = push_agent_node(row?, nodes);
agents.push(info);
}
Ok(agents)
}
fn push_agent_node(row: AgentRow, nodes: &mut Vec<Node>) -> AgentInfo {
let (id, branch, _par, model, outcome, status, cost, tin, tout, fork_pid) = row;
let branch = branch.unwrap_or_default();
let model_s = model.unwrap_or_default();
let outcome_s = outcome.unwrap_or_default();
let status_s = status.unwrap_or_default();
let cost_usd = cost.unwrap_or(0) as f64 / 1_000_000.0;
let node_id = sanitize_id(&id);
let mut extra = HashMap::new();
extra.insert("model".to_string(), model_s.clone());
extra.insert("cost_usd".to_string(), format!("{:.4}", cost_usd));
extra.insert("outcome".to_string(), outcome_s.clone());
extra.insert("status".to_string(), status_s.clone());
extra.insert("tokens_in".to_string(), tin.unwrap_or(0).to_string());
extra.insert("tokens_out".to_string(), tout.unwrap_or(0).to_string());
nodes.push(Node {
id: node_id.clone(),
title: format!("agent: {} ({}, {})", truncate_chars(&branch, 40), model_s, outcome_s),
kind: "agent".to_string(),
category: model_s,
tags: vec![outcome_s, status_s],
connections: 0,
extra,
});
AgentInfo { node_id, branch, fork_parent_id: fork_pid }
}
pub fn collect_manifests(dir: &PathBuf, nodes: &mut Vec<Node>) -> Result<Vec<ManifestInfo>> {
let mut manifests = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else { return Ok(manifests) };
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") { continue; }
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
let content = std::fs::read_to_string(&path).unwrap_or_default();
let val: toml::Value = toml::from_str(&content)
.unwrap_or(toml::Value::Table(Default::default()));
let blocks = extract_str_array(&val, "blocks");
let rule_blocks = extract_str_array(&val, "rule_blocks");
let path_refs = extract_path_refs(&val);
let substrate = val.get("substrate_role")
.and_then(|v| v.as_str()).unwrap_or("agent").to_string();
let node_id = format!("manifest::{}", sanitize_id(&stem));
nodes.push(Node {
id: node_id.clone(),
title: format!("manifest: {}", stem),
kind: "manifest".to_string(),
category: substrate,
tags: vec![],
connections: 0,
extra: HashMap::new(),
});
manifests.push(ManifestInfo { node_id, name: stem, blocks, path_refs, rule_blocks });
}
Ok(manifests)
}
pub fn collect_branches(led: &Connection, nodes: &mut Vec<Node>) -> Result<()> {
let mut stmt = led.prepare(
"SELECT branch, parent_branch FROM agents WHERE branch IS NOT NULL",
)?;
let mut seen = std::collections::HashSet::new();
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, Option<String>>(0)?, r.get::<_, Option<String>>(1)?))
})?;
for row in rows {
let (branch, parent) = row?;
let mut candidates: Vec<String> = Vec::new();
if let Some(b) = branch { candidates.push(b); }
if let Some(p) = parent { candidates.push(p); }
for b in candidates {
if seen.insert(b.clone()) {
nodes.push(Node {
id: format!("branch::{}", sanitize_id(&b)),
title: format!("branch: {}", truncate_chars(&b, 50)),
kind: "branch".to_string(),
category: "branch".to_string(),
tags: vec![],
connections: 0,
extra: HashMap::new(),
});
}
}
}
Ok(())
}
pub fn collect_skills(led: &Connection, nodes: &mut Vec<Node>) -> Result<()> {
let mut stmt = led.prepare(
"SELECT DISTINCT skill_name FROM skill_invocations WHERE skill_name IS NOT NULL",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
for row in rows {
let name = row?;
nodes.push(Node {
id: format!("skill::{}", sanitize_id(&name)),
title: format!("skill: {}", name),
kind: "skill".to_string(),
category: "skill".to_string(),
tags: vec![],
connections: 0,
extra: HashMap::new(),
});
}
Ok(())
}
fn extract_str_array(val: &toml::Value, key: &str) -> Vec<String> {
val.get(key).and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
}
fn extract_path_refs(val: &toml::Value) -> Vec<String> {
let extras = val.get("references")
.and_then(|r| r.get("extra"))
.and_then(|e| e.as_array());
let Some(arr) = extras else { return vec![] };
arr.iter().filter_map(|v| v.as_str())
.filter(|s| s.starts_with("path:"))
.map(|s| {
let after = &s["path:".len()..];
after.split('/').next().unwrap_or(after).to_string()
})
.collect()
}

View file

@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Node {
pub id: String,
pub title: String,
#[serde(rename = "type")]
pub kind: String,
pub category: String,
pub tags: Vec<String>,
pub connections: usize,
pub extra: HashMap<String, String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Edge {
pub source: String,
pub target: String,
#[serde(rename = "type")]
pub kind: String,
pub weight: f32,
}
#[derive(Debug, Serialize)]
pub struct SpaceData {
pub nodes: Vec<Node>,
pub links: Vec<Edge>,
}
#[derive(Debug, Serialize)]
pub struct Space {
pub name: &'static str,
pub icon: &'static str,
pub description: &'static str,
pub colors: HashMap<String, String>,
pub data: SpaceData,
}
pub fn sanitize_id(s: &str) -> String {
s.chars()
.map(|c| if c.is_alphanumeric() || "-_:/.".contains(c) { c } else { '_' })
.collect()
}
pub fn dna_prefix(dna: &str) -> String {
sanitize_id(&dna.chars().take(30).collect::<String>())
}
pub fn truncate_chars(s: &str, max: usize) -> &str {
if s.chars().count() <= max { return s; }
let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len());
&s[..end]
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>io.keisei.graph-export</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>HOOKS_DIR/graph-export-watcher.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>HOME_DIR/.claude/memory/graph-export.log</string>
<key>StandardErrorPath</key>
<string>HOME_DIR/.claude/memory/graph-export.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:HOME_DIR/.cargo/bin</string>
<key>HOME</key>
<string>HOME_DIR</string>
</dict>
</dict>
</plist>

View file

@ -1,19 +1,19 @@
# KeiSeiKit DNA Encyclopedia # KeiSeiKit DNA Encyclopedia
> Auto-generated from kei-registry. Last regenerated: 2026-05-01T19:42:09Z. > Auto-generated from kei-registry. Last regenerated: 2026-05-02T05:07:21Z.
> Total blocks: 515. Per-type breakdown: > Total blocks: 518. Per-type breakdown:
| Type | Count | | Type | Count |
|---|---:| |---|---:|
| atom | 121 | | atom | 121 |
| hook | 43 | | hook | 45 |
| primitive | 109 | | primitive | 110 |
| rule | 174 | | rule | 174 |
| skill | 68 | | skill | 68 |
--- ---
## Primitive (109) ## Primitive (110)
Sorted alphabetically by name. Sorted alphabetically by name.
@ -63,6 +63,7 @@ Sorted alphabetically by name.
| kei-git-gitea | primitive::md,networ… | _primitives/_rust/kei-git-gitea/Cargo.toml | 0de210a2 | | kei-git-gitea | primitive::md,networ… | _primitives/_rust/kei-git-gitea/Cargo.toml | 0de210a2 |
| kei-git-gitlab | primitive::md,networ… | _primitives/_rust/kei-git-gitlab/Cargo.toml | 59a5271b | | kei-git-gitlab | primitive::md,networ… | _primitives/_rust/kei-git-gitlab/Cargo.toml | 59a5271b |
| kei-graph-check | primitive::cli,fs,md… | _primitives/_rust/kei-graph-check/Cargo.toml | 2c0e38d8 | | kei-graph-check | primitive::cli,fs,md… | _primitives/_rust/kei-graph-check/Cargo.toml | 2c0e38d8 |
| kei-graph-export | primitive::cli,md,sq… | _primitives/_rust/kei-graph-export/Cargo.toml | de93b403 |
| kei-hibernate | primitive::cli,hash,… | _primitives/_rust/kei-hibernate/Cargo.toml | 1ea136f5 | | kei-hibernate | primitive::cli,hash,… | _primitives/_rust/kei-hibernate/Cargo.toml | 1ea136f5 |
| kei-import-project | primitive::cli,fs,ha… | _primitives/_rust/kei-import-project/Cargo.toml | 2de0fd64 | | kei-import-project | primitive::cli,fs,ha… | _primitives/_rust/kei-import-project/Cargo.toml | 2de0fd64 |
| kei-leak-matrix | primitive::cli,fs,md… | _primitives/_rust/kei-leak-matrix/Cargo.toml | a3803ef9 | | kei-leak-matrix | primitive::cli,fs,md… | _primitives/_rust/kei-leak-matrix/Cargo.toml | a3803ef9 |
@ -838,7 +839,7 @@ Sorted alphabetically by name.
| sleep-layer::the-rule | rule::_::576bbb7f::d… | d0e03a0d | | sleep-layer::the-rule | rule::_::576bbb7f::d… | d0e03a0d |
## Hook (43) ## Hook (45)
Sorted alphabetically by name. Sorted alphabetically by name.
@ -867,6 +868,7 @@ Sorted alphabetically by name.
| disk-reclaim | shell | hook::shell::47b7bf4… | hooks/disk-reclaim.sh | | disk-reclaim | shell | hook::shell::47b7bf4… | hooks/disk-reclaim.sh |
| error-spike-detector | shell | hook::shell::90dd8c6… | hooks/error-spike-detector.sh | | error-spike-detector | shell | hook::shell::90dd8c6… | hooks/error-spike-detector.sh |
| extract-task-durations | shell | hook::shell::6b3a57f… | hooks/extract-task-durations.sh | | extract-task-durations | shell | hook::shell::6b3a57f… | hooks/extract-task-durations.sh |
| graph-export-watcher | shell | hook::shell::8d87d02… | hooks/graph-export-watcher.sh |
| milestone-commit-hook | shell | hook::shell::18347ff… | hooks/milestone-commit-hook.sh | | milestone-commit-hook | shell | hook::shell::18347ff… | hooks/milestone-commit-hook.sh |
| no-downgrade | shell | hook::shell::db31e58… | hooks/no-downgrade.sh | | no-downgrade | shell | hook::shell::db31e58… | hooks/no-downgrade.sh |
| no-hand-edit-agents | shell | hook::shell::ed728f1… | hooks/no-hand-edit-agents.sh | | no-hand-edit-agents | shell | hook::shell::ed728f1… | hooks/no-hand-edit-agents.sh |
@ -884,6 +886,7 @@ Sorted alphabetically by name.
| session-end-dump | shell | hook::shell::7c3e2d9… | hooks/session-end-dump.sh | | session-end-dump | shell | hook::shell::7c3e2d9… | hooks/session-end-dump.sh |
| site-wysiwyd-check | shell | hook::shell::0683fa8… | hooks/site-wysiwyd-check.sh | | site-wysiwyd-check | shell | hook::shell::0683fa8… | hooks/site-wysiwyd-check.sh |
| skill-record | shell | hook::shell::954ccee… | hooks/skill-record.sh | | skill-record | shell | hook::shell::954ccee… | hooks/skill-record.sh |
| sleep-report-tg | shell | hook::shell::2e5b134… | hooks/sleep-report-tg.sh |
| stop-verify | shell | hook::shell::adedcfe… | hooks/stop-verify.sh | | stop-verify | shell | hook::shell::adedcfe… | hooks/stop-verify.sh |
| task-timer | shell | hook::shell::dda5e94… | hooks/task-timer.sh | | task-timer | shell | hook::shell::dda5e94… | hooks/task-timer.sh |
| tomd-preread | shell | hook::shell::8a95b76… | hooks/tomd-preread.sh | | tomd-preread | shell | hook::shell::8a95b76… | hooks/tomd-preread.sh |
@ -1079,6 +1082,7 @@ Sorted alphabetically by name.
- `kei-git-gitea` — 2 versions: ea30f0cc → 0de210a2 - `kei-git-gitea` — 2 versions: ea30f0cc → 0de210a2
- `kei-git-gitlab` — 2 versions: 744859c4 → 59a5271b - `kei-git-gitlab` — 2 versions: 744859c4 → 59a5271b
- `kei-graph-check` — 2 versions: e08f240e → 2c0e38d8 - `kei-graph-check` — 2 versions: e08f240e → 2c0e38d8
- `kei-graph-export::kei-graph-export` — 26 versions: 2e9d962a → b0f840b1 → 4a42d5f4 → a9d35468 → 1f0c066f → 6f5cd1a9 → 89ae1693 → fbebe21d → 63b761f6 → 643d3f08 → 7ba05286 → ca606a00 → c1f97c41 → 237d050b → 094ddc72 → 006b0f7d → c3d7c243 → a67fc02f → 33beda01 → 615a6cfb → 6dbfd254 → bb6ca1bb → 48cb9c62 → 5529822c → 1b597838 → f17c1aeb
- `kei-hibernate` — 2 versions: 25f6d5bc → 1ea136f5 - `kei-hibernate` — 2 versions: 25f6d5bc → 1ea136f5
- `kei-import-project` — 2 versions: aa3750a0 → 2de0fd64 - `kei-import-project` — 2 versions: aa3750a0 → 2de0fd64
- `kei-leak-matrix` — 2 versions: 06a89af2 → a3803ef9 - `kei-leak-matrix` — 2 versions: 06a89af2 → a3803ef9
@ -1146,9 +1150,11 @@ Sorted alphabetically by name.
- `no-python-without-approval` — 2 versions: 45d3e0ab → 48fdb89e - `no-python-without-approval` — 2 versions: 45d3e0ab → 48fdb89e
- `numeric-claims-guard` — 2 versions: 90f697e6 → d5ed33c8 - `numeric-claims-guard` — 2 versions: 90f697e6 → d5ed33c8
- `numeric-claims-record` — 2 versions: 59a9990f → 342361a3 - `numeric-claims-record` — 2 versions: 59a9990f → 342361a3
- `phase-b-rem` — 4 versions: 69fdc9bc → df6af06f → 223c0c99 → 8545aba8
- `post-write-check` — 2 versions: 6ceb2237 → 4aaf1c5e - `post-write-check` — 2 versions: 6ceb2237 → 4aaf1c5e
- `safety-guard` — 2 versions: 32b889cf → 665e7cd1 - `safety-guard` — 2 versions: 32b889cf → 665e7cd1
- `site-wysiwyd-check` — 2 versions: a0d38a22 → 416c0648 - `site-wysiwyd-check` — 2 versions: a0d38a22 → 416c0648
- `sleep-report-tg` — 3 versions: acc3ebfb → ef101ab6 → 9529ec50
- `ssh-check` — 2 versions: f419e2b0 → ebd97541 - `ssh-check` — 2 versions: f419e2b0 → ebd97541
- `task-timer` — 2 versions: 202823f9 → 16e4f0a3 - `task-timer` — 2 versions: 202823f9 → 16e4f0a3
- `tokens-sync` — 2 versions: 54c149ab → 69857925 - `tokens-sync` — 2 versions: 54c149ab → 69857925

18
hooks/graph-export-watcher.sh Executable file
View file

@ -0,0 +1,18 @@
#!/bin/sh
# graph-export-watcher.sh — polls kei-graph-export and writes data-runtime.js atomically.
# Bypass: GRAPH_EXPORT_BYPASS=1
INTERVAL="${KEI_GRAPH_EXPORT_INTERVAL_S:-5}"
OUT="${KEI_GRAPH_VIZ_DIR:-$HOME/Projects/lbm-graph-viz}/data-runtime.js"
BIN="$(command -v kei-graph-export 2>/dev/null || echo "$HOME/.cargo/bin/kei-graph-export")"
[ -x "$BIN" ] || exit 0
[ "${GRAPH_EXPORT_BYPASS:-0}" = "1" ] && exit 0
mkdir -p "$(dirname "$OUT")" 2>/dev/null
while true; do
"$BIN" --format spaces-fragment --output "$OUT.tmp" 2>/dev/null \
&& mv "$OUT.tmp" "$OUT" 2>/dev/null
sleep "$INTERVAL"
done