diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index a47aed0..6592154 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -3663,6 +3663,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "kei-graph-export" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "rusqlite", + "serde", + "serde_json", + "toml", +] + [[package]] name = "kei-hibernate" version = "0.1.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 8dc7c2c..6c8afd6 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -177,6 +177,8 @@ members = [ "kei-mcp", # SQL ↔ TypeScript schema drift detector "kei-db-contract", + # Live runtime-graph exporter (registry + ledger → D3 space fragment) + "kei-graph-export", ] [workspace.package] diff --git a/_primitives/_rust/kei-graph-export/Cargo.toml b/_primitives/_rust/kei-graph-export/Cargo.toml new file mode 100644 index 0000000..a9210e0 --- /dev/null +++ b/_primitives/_rust/kei-graph-export/Cargo.toml @@ -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 } diff --git a/_primitives/_rust/kei-graph-export/src/edges.rs b/_primitives/_rust/kei-graph-export/src/edges.rs new file mode 100644 index 0000000..ccc9b15 --- /dev/null +++ b/_primitives/_rust/kei-graph-export/src/edges.rs @@ -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, + links: &mut Vec, +) { + 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, + links: &mut Vec, +) { + 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, + links: &mut Vec, +) { + 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) -> 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) { + 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) -> 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, +) { + 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, + } +} diff --git a/_primitives/_rust/kei-graph-export/src/graph.rs b/_primitives/_rust/kei-graph-export/src/graph.rs new file mode 100644 index 0000000..e1de49c --- /dev/null +++ b/_primitives/_rust/kei-graph-export/src/graph.rs @@ -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 { + let reg = Connection::open(&cfg.registry_db)?; + let led = Connection::open(&cfg.ledger_db)?; + + let (mut nodes, block_lookup) = nodes::collect_blocks(®)?; + 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, links: &[Edge]) { + let mut degree: HashMap = 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 { + [ + ("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() +} diff --git a/_primitives/_rust/kei-graph-export/src/main.rs b/_primitives/_rust/kei-graph-export/src/main.rs new file mode 100644 index 0000000..84990f2 --- /dev/null +++ b/_primitives/_rust/kei-graph-export/src/main.rs @@ -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, + + #[arg(long)] + ledger_db: Option, + + #[arg(long)] + manifests_dir: Option, + + #[arg(long, default_value = "spaces-fragment")] + format: String, + + #[arg(long, short = 'o')] + output: Option, +} + +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(()) +} diff --git a/_primitives/_rust/kei-graph-export/src/nodes.rs b/_primitives/_rust/kei-graph-export/src/nodes.rs new file mode 100644 index 0000000..dee84cb --- /dev/null +++ b/_primitives/_rust/kei-graph-export/src/nodes.rs @@ -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, + pub path_refs: Vec, + pub rule_blocks: Vec, +} + +pub struct AgentInfo { + pub node_id: String, + pub branch: String, + pub fork_parent_id: Option, +} + +pub fn collect_blocks(reg: &Connection) -> Result<(Vec, HashMap)> { + // 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 = 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, Option, Option, + Option, Option, Option, Option, + Option, Option); + +pub fn collect_agents(led: &Connection, nodes: &mut Vec) -> Result> { + 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>(1)?, + r.get::<_, Option>(2)?, r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, r.get::<_, Option>(5)?, + r.get::<_, Option>(6)?, r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, r.get::<_, Option>(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) -> 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) -> Result> { + 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) -> 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>(0)?, r.get::<_, Option>(1)?)) + })?; + for row in rows { + let (branch, parent) = row?; + let mut candidates: Vec = 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) -> 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 { + 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 { + 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() +} + diff --git a/_primitives/_rust/kei-graph-export/src/types.rs b/_primitives/_rust/kei-graph-export/src/types.rs new file mode 100644 index 0000000..b17df0b --- /dev/null +++ b/_primitives/_rust/kei-graph-export/src/types.rs @@ -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, + pub connections: usize, + pub extra: HashMap, +} + +#[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, + pub links: Vec, +} + +#[derive(Debug, Serialize)] +pub struct Space { + pub name: &'static str, + pub icon: &'static str, + pub description: &'static str, + pub colors: HashMap, + 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::()) +} + +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] +} diff --git a/_primitives/templates/io.keisei.graph-export.plist b/_primitives/templates/io.keisei.graph-export.plist new file mode 100644 index 0000000..cd25fe5 --- /dev/null +++ b/_primitives/templates/io.keisei.graph-export.plist @@ -0,0 +1,35 @@ + + + + + Label + io.keisei.graph-export + + ProgramArguments + + /bin/sh + HOOKS_DIR/graph-export-watcher.sh + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + HOME_DIR/.claude/memory/graph-export.log + + StandardErrorPath + HOME_DIR/.claude/memory/graph-export.log + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:HOME_DIR/.cargo/bin + HOME + HOME_DIR + + + diff --git a/docs/DNA-INDEX.md b/docs/DNA-INDEX.md index 8b67da5..bd42198 100644 --- a/docs/DNA-INDEX.md +++ b/docs/DNA-INDEX.md @@ -1,19 +1,19 @@ # KeiSeiKit DNA Encyclopedia -> Auto-generated from kei-registry. Last regenerated: 2026-05-01T19:42:09Z. -> Total blocks: 515. Per-type breakdown: +> Auto-generated from kei-registry. Last regenerated: 2026-05-02T05:07:21Z. +> Total blocks: 518. Per-type breakdown: | Type | Count | |---|---:| | atom | 121 | -| hook | 43 | -| primitive | 109 | +| hook | 45 | +| primitive | 110 | | rule | 174 | | skill | 68 | --- -## Primitive (109) +## Primitive (110) 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-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-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-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 | @@ -838,7 +839,7 @@ Sorted alphabetically by name. | sleep-layer::the-rule | rule::_::576bbb7f::d… | d0e03a0d | -## Hook (43) +## Hook (45) Sorted alphabetically by name. @@ -867,6 +868,7 @@ Sorted alphabetically by name. | disk-reclaim | shell | hook::shell::47b7bf4… | hooks/disk-reclaim.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 | +| graph-export-watcher | shell | hook::shell::8d87d02… | hooks/graph-export-watcher.sh | | milestone-commit-hook | shell | hook::shell::18347ff… | hooks/milestone-commit-hook.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 | @@ -884,6 +886,7 @@ Sorted alphabetically by name. | session-end-dump | shell | hook::shell::7c3e2d9… | hooks/session-end-dump.sh | | site-wysiwyd-check | shell | hook::shell::0683fa8… | hooks/site-wysiwyd-check.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 | | task-timer | shell | hook::shell::dda5e94… | hooks/task-timer.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-gitlab` — 2 versions: 744859c4 → 59a5271b - `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-import-project` — 2 versions: aa3750a0 → 2de0fd64 - `kei-leak-matrix` — 2 versions: 06a89af2 → a3803ef9 @@ -1146,9 +1150,11 @@ Sorted alphabetically by name. - `no-python-without-approval` — 2 versions: 45d3e0ab → 48fdb89e - `numeric-claims-guard` — 2 versions: 90f697e6 → d5ed33c8 - `numeric-claims-record` — 2 versions: 59a9990f → 342361a3 +- `phase-b-rem` — 4 versions: 69fdc9bc → df6af06f → 223c0c99 → 8545aba8 - `post-write-check` — 2 versions: 6ceb2237 → 4aaf1c5e - `safety-guard` — 2 versions: 32b889cf → 665e7cd1 - `site-wysiwyd-check` — 2 versions: a0d38a22 → 416c0648 +- `sleep-report-tg` — 3 versions: acc3ebfb → ef101ab6 → 9529ec50 - `ssh-check` — 2 versions: f419e2b0 → ebd97541 - `task-timer` — 2 versions: 202823f9 → 16e4f0a3 - `tokens-sync` — 2 versions: 54c149ab → 69857925 diff --git a/hooks/graph-export-watcher.sh b/hooks/graph-export-watcher.sh new file mode 100755 index 0000000..a2ae35a --- /dev/null +++ b/hooks/graph-export-watcher.sh @@ -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