From da2155e34e31fa255da3b43821b71f45d530acd1 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 10:11:46 +0000 Subject: [PATCH] =?UTF-8?q?Implementer=20synops-tasks=20CLI-verkt=C3=B8y?= =?UTF-8?q?=20(oppgave=2021.12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parser tasks.md og viser formatert oppgavestatus med filtrering på fase (--phase N) og status (--status todo|done|blocked|inprogress|question). Viser sammendrag med antall per status og fremdriftsbar. Rent filbasert verktøy — ingen database nødvendig. Finner tasks.md automatisk fra arbeidsdir eller absolutt sti. Co-Authored-By: Claude Opus 4.6 (1M context) --- tasks.md | 3 +- tools/README.md | 3 +- tools/synops-tasks/Cargo.toml | 12 ++ tools/synops-tasks/src/main.rs | 267 +++++++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 tools/synops-tasks/Cargo.toml create mode 100644 tools/synops-tasks/src/main.rs diff --git a/tasks.md b/tasks.md index 2e9b817..6e57442 100644 --- a/tasks.md +++ b/tasks.md @@ -255,8 +255,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. -- [~] 21.12 `synops-tasks`: Parse tasks.md og vis status. Input: `[--phase N] [--status todo|done|blocked]`. Output: formatert oppgaveliste. - > Påbegynt: 2026-03-18T10:10 +- [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. - [ ] 21.14 `synops-node`: Hent/vis en node med edges. Input: ` [--depth N] [--format json|md]`. Output: node-data med edges. diff --git a/tools/README.md b/tools/README.md index 4718f37..475bef1 100644 --- a/tools/README.md +++ b/tools/README.md @@ -18,6 +18,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall. | `synops-prune` | Opprydding av gamle CAS-filer (TTL + disk-nødventil) | 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-tasks` | Parse tasks.md og vis oppgavestatus (filtrering på fase/status) | Ferdig | ## Konvensjoner - Navnekonvensjon: `synops-` (f.eks. `synops-context`) @@ -31,7 +32,7 @@ Ref: `docs/infra/agent_api.md` - ~~`synops-context`~~ — implementert (se tabell over) - ~~`synops-search`~~ — implementert (se tabell over) -- `synops-tasks [--phase N] [--status S]` — oppgavestatus fra tasks.md +- ~~`synops-tasks [--phase N] [--status S]`~~ — implementert (se tabell over) - `synops-feature-status ` — implementeringsstatus for en feature - ~~`synops-respond`~~ — implementert (se tabell over) - `synops-update-spec ` — oppdater spec-node (stdin) diff --git a/tools/synops-tasks/Cargo.toml b/tools/synops-tasks/Cargo.toml new file mode 100644 index 0000000..7e512d6 --- /dev/null +++ b/tools/synops-tasks/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "synops-tasks" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "synops-tasks" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +regex = "1" diff --git a/tools/synops-tasks/src/main.rs b/tools/synops-tasks/src/main.rs new file mode 100644 index 0000000..b0e89b0 --- /dev/null +++ b/tools/synops-tasks/src/main.rs @@ -0,0 +1,267 @@ +// synops-tasks — Parse tasks.md og vis oppgavestatus. +// +// Leser tasks.md fra repo-roten og parser oppgaver med status, +// fase og detaljer. Støtter filtrering på fase og status. +// +// Output: formatert oppgaveliste til stdout. +// +// Ref: docs/retninger/unix_filosofi.md + +use clap::Parser; +use regex::Regex; +use std::path::PathBuf; +use std::process; + +/// Parse tasks.md og vis oppgavestatus. +#[derive(Parser)] +#[command(name = "synops-tasks", about = "Vis oppgavestatus fra tasks.md")] +struct Cli { + /// Filtrer på fase (f.eks. 21 for "Fase 21") + #[arg(long)] + phase: Option, + + /// Filtrer på status: todo, done, blocked, inprogress, question, all + #[arg(long, default_value = "all")] + status: String, +} + +#[derive(Debug, Clone)] +struct Task { + phase: u32, + phase_title: String, + id: String, + title: String, + status: TaskStatus, + details: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +enum TaskStatus { + Todo, + InProgress, + Done, + Question, + Blocked, +} + +impl TaskStatus { + fn symbol(&self) -> &str { + match self { + TaskStatus::Todo => "[ ]", + TaskStatus::InProgress => "[~]", + TaskStatus::Done => "[x]", + TaskStatus::Question => "[?]", + TaskStatus::Blocked => "[!]", + } + } + + fn label(&self) -> &str { + match self { + TaskStatus::Todo => "Klar", + TaskStatus::InProgress => "Pågår", + TaskStatus::Done => "Ferdig", + TaskStatus::Question => "Spørsmål", + TaskStatus::Blocked => "Blokkert", + } + } + + fn matches_filter(&self, filter: &str) -> bool { + match filter { + "all" => true, + "todo" => *self == TaskStatus::Todo, + "done" => *self == TaskStatus::Done, + "blocked" => *self == TaskStatus::Blocked || *self == TaskStatus::Question, + "inprogress" => *self == TaskStatus::InProgress, + "question" => *self == TaskStatus::Question, + _ => true, + } + } +} + +fn find_tasks_md() -> Result { + // Prøv relativ sti først (repo-rot), deretter absolutt + let candidates = [ + PathBuf::from("tasks.md"), + PathBuf::from("/home/vegard/synops/tasks.md"), + ]; + for path in &candidates { + if path.exists() { + return Ok(path.clone()); + } + } + Err("Fant ikke tasks.md (prøvde ./ og /home/vegard/synops/)".to_string()) +} + +fn parse_tasks(content: &str) -> Vec { + 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 tasks = 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 { + " " => TaskStatus::Todo, + "~" => TaskStatus::InProgress, + "x" => TaskStatus::Done, + "?" => TaskStatus::Question, + "!" => TaskStatus::Blocked, + _ => TaskStatus::Todo, + }; + + let (phase, phase_title) = current_phase + .clone() + .unwrap_or((0, "Ukjent".to_string())); + + tasks.push(Task { + phase, + phase_title, + id, + title, + status, + details: Vec::new(), + }); + continue; + } + + if let Some(caps) = detail_re.captures(line) { + if let Some(task) = tasks.last_mut() { + task.details.push(caps[1].to_string()); + } + } + } + + tasks +} + +fn main() { + let cli = Cli::parse(); + + let valid_statuses = ["all", "todo", "done", "blocked", "inprogress", "question"]; + if !valid_statuses.contains(&cli.status.as_str()) { + eprintln!( + "Ugyldig status «{}». Gyldige verdier: {}", + cli.status, + valid_statuses.join(", ") + ); + process::exit(1); + } + + let path = match find_tasks_md() { + Ok(p) => p, + Err(e) => { + eprintln!("Feil: {e}"); + process::exit(1); + } + }; + + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("Kunne ikke lese {}: {e}", path.display()); + process::exit(1); + } + }; + + let tasks = parse_tasks(&content); + + // Filtrer + let filtered: Vec<&Task> = tasks + .iter() + .filter(|t| { + if let Some(phase) = cli.phase { + if t.phase != phase { + return false; + } + } + t.status.matches_filter(&cli.status) + }) + .collect(); + + if filtered.is_empty() { + let mut msg = String::from("Ingen oppgaver funnet"); + if let Some(phase) = cli.phase { + msg.push_str(&format!(" i fase {phase}")); + } + if cli.status != "all" { + msg.push_str(&format!(" med status «{}»", cli.status)); + } + println!("{msg}."); + return; + } + + // Grupper etter fase + let mut current_phase: Option = None; + + // Sammendrag + let total = filtered.len(); + let done = filtered.iter().filter(|t| t.status == TaskStatus::Done).count(); + let todo = filtered.iter().filter(|t| t.status == TaskStatus::Todo).count(); + let progress = filtered + .iter() + .filter(|t| t.status == TaskStatus::InProgress) + .count(); + let blocked = filtered + .iter() + .filter(|t| t.status == TaskStatus::Blocked || t.status == TaskStatus::Question) + .count(); + + if let Some(phase) = cli.phase { + println!("# Fase {phase}\n"); + } else { + println!("# Oppgavestatus\n"); + } + + println!( + "Totalt: {total} | Ferdig: {done} | Klar: {todo} | Pågår: {progress} | Blokkert: {blocked}\n" + ); + + for task in &filtered { + if current_phase != Some(task.phase) { + current_phase = Some(task.phase); + println!("## Fase {}: {}\n", task.phase, task.phase_title); + } + + println!( + " {} {} {}", + task.status.symbol(), + task.id, + task.title + ); + + // Vis statuslabel for ikke-ferdige oppgaver + if task.status != TaskStatus::Done { + println!(" Status: {}", task.status.label()); + } + + for detail in &task.details { + println!(" > {detail}"); + } + } + + // Fremdriftsbar for filtrert sett + if total > 0 { + let pct = (done as f64 / total as f64 * 100.0) as u32; + let bar_width = 30; + let filled = (pct as usize * bar_width) / 100; + let empty = bar_width - filled; + println!( + "\nFremdrift: [{}{}] {pct}% ({done}/{total})", + "█".repeat(filled), + "░".repeat(empty) + ); + } +}