Implementer synops-feature-status CLI-verktøy (oppgave 21.13)

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 10:18:43 +00:00
parent 51b4a9aecf
commit 9fd8fc6e57
4 changed files with 593 additions and 3 deletions

View file

@ -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 <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder. - [x] 21.10 `synops-context`: Hent kontekst for en samtale. Input: `--communication-id <uuid>`. Output: markdown med spec, deltakere, historikk, relaterte noder.
- [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_kind>] [--limit N]`. Output: matchende noder med utdrag. - [x] 21.11 `synops-search`: Fulltekstsøk i grafen. Input: `<query> [--kind <node_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. - [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: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback. - [x] 21.13 `synops-feature-status`: Sjekk feature-status. Input: `<feature_key>`. Output: spec-sammendrag, oppgavestatus, nylige commits, ubesvart feedback.
> Påbegynt: 2026-03-18T10:12
- [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: `<uuid> [--depth N] [--format json|md]`. Output: node-data med edges. - [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: `<uuid> [--depth N] [--format json|md]`. Output: node-data med edges.
### Infrastruktur ### Infrastruktur

View file

@ -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-context` | Hent kontekst for en samtale (deltakere, historikk, spec, relaterte noder) | Ferdig |
| `synops-search` | Fulltekstsøk i noder (title + content, norsk tsvector) | 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-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig |
| `synops-feature-status` | Sjekk feature-status: spec, oppgaver, commits, feedback | Ferdig |
## Konvensjoner ## Konvensjoner
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`) - Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
@ -33,6 +34,6 @@ Ref: `docs/infra/agent_api.md`
- ~~`synops-context`~~ — implementert (se tabell over) - ~~`synops-context`~~ — implementert (se tabell over)
- ~~`synops-search`~~ — implementert (se tabell over) - ~~`synops-search`~~ — implementert (se tabell over)
- ~~`synops-tasks [--phase N] [--status S]`~~ — implementert (se tabell over) - ~~`synops-tasks [--phase N] [--status S]`~~ — implementert (se tabell over)
- `synops-feature-status <key>` — implementeringsstatus for en feature - ~~`synops-feature-status <key>`~~ — implementert (se tabell over)
- ~~`synops-respond`~~ — implementert (se tabell over) - ~~`synops-respond`~~ — implementert (se tabell over)
- `synops-update-spec <node_id>` — oppdater spec-node (stdin) - `synops-update-spec <node_id>` — oppdater spec-node (stdin)

View file

@ -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"

View file

@ -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<PathBuf> {
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<String> = 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<String>,
}
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<String> = 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<TaskInfo> = 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-<key> 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<u32> = 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<String> = vec![
key_lower.clone(),
key_lower.replace('-', "_"),
key_lower.replace('_', "-"),
format!("synops-{}", key_lower),
];
let file_patterns: Vec<String> = 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<String> =
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<String>,
) {
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<String> {
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<String>)> = 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<String>)> = 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<String>, chrono::DateTime<chrono::Utc>)> =
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}");
}
}
}
}