From 9fd8fc6e5788feae7be7828897d6468846b6876c Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 10:18:43 +0000 Subject: [PATCH] =?UTF-8?q?Implementer=20synops-feature-status=20CLI-verkt?= =?UTF-8?q?=C3=B8y=20(oppgave=2021.13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nytt verktøy som samler feature-status fra fire kilder: 1. Spec-sammendrag fra docs/features/ og docs/concepts/ 2. Relaterte oppgaver fra tasks.md (fuzzy-matching på nøkkel) 3. Nylige git-commits (fil-endringer + commit-meldinger) 4. Ubesvart feedback fra PG (spec-noder med discusses-edge) DATABASE_URL er valgfri — feedback hoppes over uten tilkobling. Prøver også /tmp/maskinrommet.env som fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- tasks.md | 3 +- tools/README.md | 3 +- tools/synops-feature-status/Cargo.toml | 18 + tools/synops-feature-status/src/main.rs | 572 ++++++++++++++++++++++++ 4 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 tools/synops-feature-status/Cargo.toml create mode 100644 tools/synops-feature-status/src/main.rs diff --git a/tasks.md b/tasks.md index 431893c..9f47e4a 100644 --- a/tasks.md +++ b/tasks.md @@ -256,8 +256,7 @@ kaller dem direkte. Samme verktøy, to brukere. - [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id `. Output: markdown med spec, deltakere, historikk, relaterte noder. - [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: ` [--kind ] [--limit N]`. Output: matchende noder med utdrag. - [x] 21.12 `synops-tasks`: Parse tasks.md og vis status. Input: `[--phase N] [--status todo|done|blocked]`. Output: formatert oppgaveliste. -- [~] 21.13 `synops-feature-status`: Sjekk feature-status. Input: ``. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. - > Påbegynt: 2026-03-18T10:12 +- [x] 21.13 `synops-feature-status`: Sjekk feature-status. Input: ``. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. - [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: ` [--depth N] [--format json|md]`. Output: node-data med edges. ### Infrastruktur diff --git a/tools/README.md b/tools/README.md index 475bef1..b4c8f87 100644 --- a/tools/README.md +++ b/tools/README.md @@ -19,6 +19,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `synops-context` | Hent kontekst for en samtale (deltakere, historikk, spec, relaterte noder) | Ferdig | | `synops-search` | Fulltekstsøk i noder (title + content, norsk tsvector) | Ferdig | | `synops-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig | +| `synops-feature-status` | Sjekk feature-status: spec, oppgaver, commits, feedback | Ferdig | ## Konvensjoner - Navnekonvensjon: `synops-` (f.eks. `synops-context`) @@ -33,6 +34,6 @@ Ref: `docs/infra/agent_api.md` - ~~`synops-context`~~ — implementert (se tabell over) - ~~`synops-search`~~ — implementert (se tabell over) - ~~`synops-tasks [--phase N] [--status S]`~~ — implementert (se tabell over) -- `synops-feature-status ` — implementeringsstatus for en feature +- ~~`synops-feature-status `~~ — implementert (se tabell over) - ~~`synops-respond`~~ — implementert (se tabell over) - `synops-update-spec ` — oppdater spec-node (stdin) diff --git a/tools/synops-feature-status/Cargo.toml b/tools/synops-feature-status/Cargo.toml new file mode 100644 index 0000000..94e3995 --- /dev/null +++ b/tools/synops-feature-status/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "synops-feature-status" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-feature-status" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +regex = "1" diff --git a/tools/synops-feature-status/src/main.rs b/tools/synops-feature-status/src/main.rs new file mode 100644 index 0000000..ff627e3 --- /dev/null +++ b/tools/synops-feature-status/src/main.rs @@ -0,0 +1,572 @@ +// synops-feature-status — Sjekk feature-status. +// +// Samler informasjon om en feature fra fire kilder: +// 1. Spec-fil på disk (docs/features/ eller docs/concepts/) +// 2. Oppgavestatus fra tasks.md +// 3. Nylige git-commits som berører featuren +// 4. Ubesvart feedback fra databasen (kommunikasjonsnoder med discusses-edge) +// +// Output: markdown-formatert sammendrag til stdout. +// +// Miljøvariabler: +// DATABASE_URL — PostgreSQL-tilkobling (valgfri — feedback hoppes over uten) +// +// Ref: docs/retninger/unix_filosofi.md, docs/features/feature_feedback.md + +use clap::Parser; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::process::{self, Command}; + +/// Sjekk feature-status: spec, oppgaver, commits og feedback. +#[derive(Parser)] +#[command(name = "synops-feature-status", about = "Sjekk feature-status")] +struct Cli { + /// Feature-nøkkel (f.eks. "lydstudio", "chat", "publisering") + feature_key: String, + + /// Antall commits å vise (default: 10) + #[arg(long, default_value_t = 10)] + commits: usize, + + /// Repo-rot (default: /home/vegard/synops) + #[arg(long, default_value = "/home/vegard/synops")] + repo: String, +} + +fn main() { + let cli = Cli::parse(); + let repo = PathBuf::from(&cli.repo); + + if !repo.join("tasks.md").exists() { + eprintln!("Feil: Fant ikke tasks.md i {}", repo.display()); + process::exit(1); + } + + let key = &cli.feature_key; + + println!("# Feature-status: {key}\n"); + + // 1. Spec-sammendrag + print_spec_summary(&repo, key); + + // 2. Oppgavestatus + print_task_status(&repo, key); + + // 3. Nylige commits + print_recent_commits(&repo, key, cli.commits); + + // 4. Ubesvart feedback (krever DATABASE_URL) + run_feedback(key); +} + +// --- Spec-sammendrag --- + +fn find_spec_file(repo: &Path, key: &str) -> Option { + let key_lower = key.to_lowercase(); + let underscore = key_lower.replace('-', "_"); + let hyphen = key_lower.replace('_', "-"); + + let candidates = [ + repo.join(format!("docs/features/{key_lower}.md")), + repo.join(format!("docs/concepts/{key_lower}.md")), + repo.join(format!("docs/features/{underscore}.md")), + repo.join(format!("docs/concepts/{underscore}.md")), + repo.join(format!("docs/features/{hyphen}.md")), + repo.join(format!("docs/concepts/{hyphen}.md")), + ]; + + for path in &candidates { + if path.exists() { + return Some(path.clone()); + } + } + + // Fuzzy: søk i begge mapper etter filer som inneholder nøkkelen + for dir in &["docs/features", "docs/concepts"] { + let dir_path = repo.join(dir); + if let Ok(entries) = std::fs::read_dir(&dir_path) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_lowercase(); + if name.ends_with(".md") && name.contains(&key_lower) { + return Some(entry.path()); + } + } + } + } + + None +} + +fn extract_summary(content: &str) -> String { + let lines: Vec<&str> = content.lines().collect(); + let mut summary_parts: Vec = Vec::new(); + + // Hent tittelen (første # heading) + for line in &lines { + if line.starts_with("# ") { + summary_parts.push(line.to_string()); + break; + } + } + + // Hent første avsnitt etter tittelen (eller etter frontmatter) + let mut in_frontmatter = false; + let mut past_title = false; + let mut paragraph = Vec::new(); + + for line in &lines { + if line.starts_with("---") { + in_frontmatter = !in_frontmatter; + continue; + } + if in_frontmatter { + continue; + } + if line.starts_with("# ") { + past_title = true; + continue; + } + if !past_title { + continue; + } + + let trimmed = line.trim(); + if trimmed.is_empty() { + if !paragraph.is_empty() { + break; + } + continue; + } + if trimmed.starts_with('#') { + if !paragraph.is_empty() { + break; + } + continue; + } + paragraph.push(trimmed.to_string()); + } + + if !paragraph.is_empty() { + summary_parts.push(paragraph.join(" ")); + } + + // Hent ## headings som oversikt + let headings: Vec<&str> = lines + .iter() + .filter(|l| l.starts_with("## ")) + .copied() + .collect(); + + if !headings.is_empty() { + summary_parts.push(String::new()); + summary_parts.push("**Seksjoner:**".to_string()); + for h in &headings { + summary_parts.push(format!("- {}", h.trim_start_matches("## "))); + } + } + + summary_parts.join("\n") +} + +fn print_spec_summary(repo: &Path, key: &str) { + println!("## Spec\n"); + + match find_spec_file(repo, key) { + Some(path) => { + let rel_path = path + .strip_prefix(repo) + .unwrap_or(&path) + .display() + .to_string(); + + match std::fs::read_to_string(&path) { + Ok(content) => { + println!("Kilde: `{rel_path}`\n"); + let summary = extract_summary(&content); + if summary.is_empty() { + println!("_(Tom spec-fil)_\n"); + } else { + println!("{summary}\n"); + } + } + Err(e) => println!("Kunne ikke lese {rel_path}: {e}\n"), + } + } + None => { + println!("Ingen spec-fil funnet for «{key}»."); + println!("Søkte i: docs/features/, docs/concepts/\n"); + } + } +} + +// --- Oppgavestatus --- + +struct TaskInfo { + phase: u32, + phase_title: String, + id: String, + title: String, + status: String, + details: Vec, +} + +fn print_task_status(repo: &Path, key: &str) { + println!("## Oppgaver\n"); + + let tasks_path = repo.join("tasks.md"); + let content = match std::fs::read_to_string(&tasks_path) { + Ok(c) => c, + Err(e) => { + println!("Kunne ikke lese tasks.md: {e}\n"); + return; + } + }; + + let key_lower = key.to_lowercase(); + let search_terms: Vec = vec![ + key_lower.clone(), + key_lower.replace('-', "_"), + key_lower.replace('_', "-"), + key_lower.replace('_', " "), + key_lower.replace('-', " "), + ]; + + let phase_re = Regex::new(r"^## Fase (\d+):\s*(.+)$").unwrap(); + let task_re = Regex::new(r"^- \[(.)\] (\d+\.\d+)\s+(.+)$").unwrap(); + let detail_re = Regex::new(r"^ > (.+)$").unwrap(); + + let mut all_tasks: Vec = Vec::new(); + let mut current_phase: Option<(u32, String)> = None; + + for line in content.lines() { + if let Some(caps) = phase_re.captures(line) { + let num: u32 = caps[1].parse().unwrap_or(0); + let title = caps[2].trim().to_string(); + current_phase = Some((num, title)); + continue; + } + + if let Some(caps) = task_re.captures(line) { + let status_char = &caps[1]; + let id = caps[2].to_string(); + let title = caps[3].to_string(); + + let status = match status_char { + " " => "[ ]", + "~" => "[~]", + "x" => "[x]", + "?" => "[?]", + "!" => "[!]", + _ => "[?]", + } + .to_string(); + + let (phase, phase_title) = current_phase + .clone() + .unwrap_or((0, "Ukjent".to_string())); + + all_tasks.push(TaskInfo { + phase, + phase_title, + id, + title, + status, + details: Vec::new(), + }); + continue; + } + + if let Some(caps) = detail_re.captures(line) { + if let Some(task) = all_tasks.last_mut() { + task.details.push(caps[1].to_string()); + } + } + } + + // Finn oppgaver som matcher feature-nøkkelen + let mut matching: Vec<&TaskInfo> = all_tasks + .iter() + .filter(|t| { + let title_lower = t.title.to_lowercase(); + let phase_lower = t.phase_title.to_lowercase(); + search_terms + .iter() + .any(|term| title_lower.contains(term) || phase_lower.contains(term)) + }) + .collect(); + + // Også prøv synops- hvis ingenting funnet + if matching.is_empty() { + let tool_name = format!("synops-{}", key_lower); + matching = all_tasks + .iter() + .filter(|t| t.title.to_lowercase().contains(&tool_name)) + .collect(); + } + + if matching.is_empty() { + println!("Ingen oppgaver funnet for «{key}».\n"); + return; + } + + let total = matching.len(); + let done = matching.iter().filter(|t| t.status == "[x]").count(); + + println!("Funnet {total} relaterte oppgaver ({done}/{total} ferdig):\n"); + + let mut current_ph: Option = None; + for task in &matching { + if current_ph != Some(task.phase) { + current_ph = Some(task.phase); + println!("### Fase {}: {}\n", task.phase, task.phase_title); + } + println!("- {} {} {}", task.status, task.id, task.title); + for detail in &task.details { + println!(" > {detail}"); + } + } + println!(); +} + +// --- Git-commits --- + +fn print_recent_commits(repo: &Path, key: &str, max: usize) { + println!("## Nylige commits\n"); + + let key_lower = key.to_lowercase(); + let search_patterns: Vec = vec![ + key_lower.clone(), + key_lower.replace('-', "_"), + key_lower.replace('_', "-"), + format!("synops-{}", key_lower), + ]; + + let file_patterns: Vec = vec![ + format!("docs/features/{}*", key_lower), + format!("docs/features/{}*", key_lower.replace('-', "_")), + format!("docs/concepts/{}*", key_lower), + format!("docs/concepts/{}*", key_lower.replace('-', "_")), + format!("tools/synops-{}*", key_lower), + ]; + + let mut commits: Vec<(String, String, String)> = Vec::new(); + let mut seen_hashes: std::collections::HashSet = + std::collections::HashSet::new(); + + // Commits fra filer + for pattern in &file_patterns { + if let Ok(output) = Command::new("git") + .args([ + "log", + "--oneline", + "--format=%h|%ai|%s", + &format!("-{max}"), + "--", + pattern, + ]) + .current_dir(repo) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + parse_commit_lines(&stdout, &mut commits, &mut seen_hashes); + } + } + + // Commits fra meldinger (--grep) + for term in &search_patterns { + if let Ok(output) = Command::new("git") + .args([ + "log", + "--oneline", + "--format=%h|%ai|%s", + &format!("-{max}"), + "-i", + "--grep", + term, + ]) + .current_dir(repo) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + parse_commit_lines(&stdout, &mut commits, &mut seen_hashes); + } + } + + // Sorter etter dato (nyeste først) + commits.sort_by(|a, b| b.1.cmp(&a.1)); + commits.truncate(max); + + if commits.is_empty() { + println!("Ingen commits funnet for «{key}».\n"); + return; + } + + println!("{} relevante commits:\n", commits.len()); + for (hash, date, msg) in &commits { + let short_date = if date.len() >= 10 { &date[..10] } else { date }; + println!("- `{hash}` ({short_date}) {msg}"); + } + println!(); +} + +fn parse_commit_lines( + stdout: &str, + commits: &mut Vec<(String, String, String)>, + seen: &mut std::collections::HashSet, +) { + for line in stdout.lines() { + let parts: Vec<&str> = line.splitn(3, '|').collect(); + if parts.len() == 3 && seen.insert(parts[0].to_string()) { + commits.push(( + parts[0].to_string(), + parts[1].to_string(), + parts[2].to_string(), + )); + } + } +} + +// --- Feedback fra database --- + +fn run_feedback(key: &str) { + let db_url = match resolve_database_url() { + Some(url) => url, + None => { + println!("## Feedback\n"); + println!("_(DATABASE_URL ikke satt — feedback hoppes over)_\n"); + return; + } + }; + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(print_feedback(&db_url, key)); +} + +fn resolve_database_url() -> Option { + if let Ok(url) = std::env::var("DATABASE_URL") { + return Some(url); + } + + // Prøv maskinrommet.env + if let Ok(content) = std::fs::read_to_string("/tmp/maskinrommet.env") { + for line in content.lines() { + if let Some(val) = line.strip_prefix("DATABASE_URL=") { + return Some(val.to_string()); + } + } + } + + None +} + +async fn print_feedback(db_url: &str, key: &str) { + println!("## Feedback\n"); + + let db = match sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(db_url) + .await + { + Ok(pool) => pool, + Err(e) => { + eprintln!("Kunne ikke koble til database: {e}"); + println!("_(Databasefeil — feedback hoppes over)_\n"); + return; + } + }; + + // Finn spec-noder med matching feature_key + let spec_nodes: Vec<(uuid::Uuid, Option)> = sqlx::query_as( + "SELECT id, title FROM nodes \ + WHERE metadata->>'feature_spec' = 'true' \ + AND LOWER(metadata->>'feature_key') = LOWER($1) \ + LIMIT 5", + ) + .bind(key) + .fetch_all(&db) + .await + .unwrap_or_default(); + + if spec_nodes.is_empty() { + println!("Ingen spec-node funnet i databasen for «{key}»."); + println!("Feature feedback-systemet er kanskje ikke satt opp for denne featuren.\n"); + return; + } + + for (node_id, title) in &spec_nodes { + let t = title.as_deref().unwrap_or("Uten tittel"); + println!("Spec-node: `{node_id}` — {t}"); + print_feedback_for_spec(&db, *node_id).await; + } + + println!(); +} + +async fn print_feedback_for_spec(db: &sqlx::PgPool, spec_node_id: uuid::Uuid) { + // Finn feedback-chatter (kommunikasjonsnoder med discusses-edge til spec) + let feedback_chats: Vec<(uuid::Uuid, Option)> = sqlx::query_as( + "SELECT n.id, n.title \ + FROM nodes n \ + JOIN edges e ON e.source_id = n.id \ + WHERE e.target_id = $1 \ + AND e.edge_type = 'discusses' \ + AND n.node_kind = 'communication' \ + LIMIT 10", + ) + .bind(spec_node_id) + .fetch_all(db) + .await + .unwrap_or_default(); + + if feedback_chats.is_empty() { + println!(" Ingen feedback-chatter koblet til denne spec-noden.\n"); + return; + } + + for (chat_id, chat_title) in &feedback_chats { + let ct = chat_title.as_deref().unwrap_or("Feedback"); + println!("\n Feedback-chat: `{chat_id}` — {ct}"); + + // Finn ubesvarte meldinger: meldinger nyere enn siste Claude-svar + let unanswered: Vec<(uuid::Uuid, Option, chrono::DateTime)> = + sqlx::query_as( + "SELECT n.id, \ + LEFT(n.content, 200) AS excerpt, \ + n.created_at \ + FROM nodes n \ + JOIN edges e ON e.source_id = n.id \ + WHERE e.target_id = $1 \ + AND e.edge_type = 'belongs_to' \ + AND n.created_at > COALESCE( \ + (SELECT MAX(n2.created_at) \ + FROM nodes n2 \ + JOIN edges e2 ON e2.source_id = n2.id \ + WHERE e2.target_id = $1 \ + AND e2.edge_type = 'belongs_to' \ + AND n2.created_by = 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44'::uuid), \ + '1970-01-01'::timestamptz) \ + AND n.created_by != 'd3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44'::uuid \ + ORDER BY n.created_at DESC \ + LIMIT 20", + ) + .bind(chat_id) + .fetch_all(db) + .await + .unwrap_or_default(); + + if unanswered.is_empty() { + println!(" Ingen ubesvart feedback."); + } else { + println!(" **{} ubesvarte meldinger:**", unanswered.len()); + for (msg_id, excerpt, created) in &unanswered { + let date = created.format("%Y-%m-%d %H:%M"); + let text = excerpt + .as_deref() + .unwrap_or("(tomt)") + .replace('\n', " "); + println!(" - `{msg_id}` ({date}): {text}"); + } + } + } +}