From c3f33fb5e24908360dd424b38dad48c2e5bce3fa Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sat, 13 Jun 2026 19:24:40 +0400 Subject: [PATCH 1/5] feat(event): add Rename event type to update task title on replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New EventType::Rename. upsert_task_from_event applies it as UPDATE tasks SET title — latest rename wins on JSONL replay, so rebuild_state stays deterministic. Enables complete/finalize to fix junk auto-titles without mutating the append-only log. claude-memory-7by Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 6 ++--- crates/tj-core/src/db.rs | 49 +++++++++++++++++++++++++++++++++++++ crates/tj-core/src/event.rs | 2 ++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f26e730..f25c8fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.21.0" +version = "0.21.1" dependencies = [ "anyhow", "assert_cmd", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.21.0" +version = "0.21.1" dependencies = [ "anyhow", "chrono", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.21.0" +version = "0.21.1" dependencies = [ "anyhow", "chrono", diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 658021f..cb8df0f 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -271,6 +271,19 @@ pub fn upsert_task_from_event( rusqlite::params![event.task_id, event.timestamp], )?; } + EventType::Rename => { + // The new human-readable title is the event text. Replaying the + // JSONL in order means the last Rename wins — exactly what we want. + let title = event + .meta + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or(&event.text); + conn.execute( + "UPDATE tasks SET title=?2, last_event_at=?3 WHERE task_id=?1", + rusqlite::params![event.task_id, title, event.timestamp], + )?; + } _ => { conn.execute( "UPDATE tasks SET last_event_at=?2 WHERE task_id=?1", @@ -1317,6 +1330,42 @@ mod tests { assert!(!task_exists(&conn, "tj-nope").unwrap()); } + #[test] + fn rename_event_updates_task_title() { + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + let ph = "feedfacefeedface"; + + let open_ev = make_open_event("tj-rn", "#: 5"); + upsert_task_from_event(&conn, &open_ev, ph).unwrap(); + let title: String = conn + .query_row("SELECT title FROM tasks WHERE task_id='tj-rn'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(title, "#: 5"); + + let mut rename = crate::event::Event::new( + "tj-rn", + crate::event::EventType::Rename, + crate::event::Author::Agent, + crate::event::Source::Cli, + "Support BID 29683996 — voucher refund 50% vs promised 100%".into(), + ); + rename.timestamp = "2099-01-01T00:00:00.000Z".into(); + upsert_task_from_event(&conn, &rename, ph).unwrap(); + + let title: String = conn + .query_row("SELECT title FROM tasks WHERE task_id='tj-rn'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!( + title, + "Support BID 29683996 — voucher refund 50% vs promised 100%" + ); + } + #[test] fn fresh_db_runs_all_migrations() { let d = TempDir::new().unwrap(); diff --git a/crates/tj-core/src/event.rs b/crates/tj-core/src/event.rs index 199749b..de603a1 100644 --- a/crates/tj-core/src/event.rs +++ b/crates/tj-core/src/event.rs @@ -16,6 +16,7 @@ pub enum EventType { Supersede, Close, Redirect, + Rename, } impl EventType { @@ -32,6 +33,7 @@ impl EventType { Self::Supersede, Self::Close, Self::Redirect, + Self::Rename, ]; } From dd48daf2e47b2934c91a3c58c1ad8883aec4dbdc Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sat, 13 Jun 2026 19:26:32 +0400 Subject: [PATCH 2/5] feat(finalize): LLM judge for title/outcome/done verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New finalize.rs: one backend call reads a task's full event history and returns {retitle, title, done, outcome_tag, outcome, reason}. The model decides whether the current title is junk and whether the events clearly show the task finished — heuristics can't tell a natural-language title fragment from a real one. Pluggable via llm::LlmBackend (Haiku default). claude-memory-c1w Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tj-core/src/finalize.rs | 206 +++++++++++++++++++++++++++++++++ crates/tj-core/src/lib.rs | 1 + 2 files changed, 207 insertions(+) create mode 100644 crates/tj-core/src/finalize.rs diff --git a/crates/tj-core/src/finalize.rs b/crates/tj-core/src/finalize.rs new file mode 100644 index 0000000..b14cbe4 --- /dev/null +++ b/crates/tj-core/src/finalize.rs @@ -0,0 +1,206 @@ +//! Finalize — bring a legacy task to a finished shape. +//! +//! One LLM call reads a task's full event history and returns a judgment: +//! a human-readable title, a one-sentence outcome, and whether the events +//! clearly show the task was finished (so `complete` may close it). The +//! model decides — same word in different contexts misleads heuristics, and +//! a title like "пТак обясни…" is natural language yet a useless title, so +//! only a reader of the whole history can call it. + +use crate::llm::LlmBackend; +use anyhow::Context; +use serde::Deserialize; + +/// The model's verdict on a task, distilled from its events. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct FinalizeJudgment { + /// True when the current title is a poor description of the task and + /// should be replaced by `title`. False echoes a good human title back. + #[serde(default)] + pub retitle: bool, + /// A short human-readable title (≈5–10 words). + #[serde(default)] + pub title: String, + /// True only when the events clearly show the task was finished. When + /// unclear, the model leaves it false and `complete` keeps it open. + #[serde(default)] + pub done: bool, + /// `done` | `abandoned` | `superseded` — only used when `done` is true. + #[serde(default)] + pub outcome_tag: String, + /// One sentence: what actually happened / where the task ended. + #[serde(default)] + pub outcome: String, + /// Short rationale for done / still-open — shown to the user. + #[serde(default)] + pub reason: String, +} + +impl FinalizeJudgment { + /// Apply the proposed title only when the model flagged the current one + /// as poor AND offered a non-empty, genuinely different replacement. + pub fn should_apply_title(&self, current_title: &str) -> bool { + self.retitle && !self.title.trim().is_empty() && self.title.trim() != current_title.trim() + } + + /// Map the model's tag to the validated close enum; falls back to `done` + /// for an empty/unknown tag so the close path never rejects it. + pub fn normalized_tag(&self) -> &str { + match self.outcome_tag.trim() { + "abandoned" => "abandoned", + "superseded" => "superseded", + _ => "done", + } + } +} + +/// Build the judge prompt from a task's current title and its event lines +/// (each line pre-formatted as `[type] text` by the caller). +pub fn build_prompt(current_title: &str, event_lines: &[String]) -> String { + let history = event_lines.join("\n"); + format!( + "You are finalizing a software task's journal. Read its full history \ +and reply with ONE JSON object, nothing else.\n\n\ +Current title: {current_title}\n\n\ +Event history (oldest first):\n{history}\n\n\ +Return exactly this JSON shape:\n\ +{{\n\ + \"retitle\": ,\n\ + \"title\": \"\",\n\ + \"done\": ,\n\ + \"outcome_tag\": \"\",\n\ + \"outcome\": \"\",\n\ + \"reason\": \"\"\n\ +}}\n\ +Be conservative about \"done\": if the history does not clearly show the task \ +was completed, set done=false." + ) +} + +/// Parse the model reply (a JSON object, possibly inside a ```json fence). +pub fn parse_judgment(text: &str) -> anyhow::Result { + let json_str = text + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + // Tolerate leading/trailing prose by slicing to the outermost braces. + let slice = match (json_str.find('{'), json_str.rfind('}')) { + (Some(a), Some(b)) if b > a => &json_str[a..=b], + _ => json_str, + }; + serde_json::from_str(slice) + .with_context(|| format!("finalize JSON parse failed; got: {json_str}")) +} + +/// One judge call: prompt → model → parsed judgment. +pub fn judge( + current_title: &str, + event_lines: &[String], + backend: &dyn LlmBackend, +) -> anyhow::Result { + let prompt = build_prompt(current_title, event_lines); + let reply = backend.complete(&prompt, 512)?; + parse_judgment(&reply) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockBackend(String); + impl LlmBackend for MockBackend { + fn complete(&self, _prompt: &str, _max_tokens: u32) -> anyhow::Result { + Ok(self.0.clone()) + } + fn name(&self) -> &'static str { + "mock" + } + } + + #[test] + fn parses_plain_json() { + let j = parse_judgment( + r#"{"retitle":true,"title":"Fix voucher refund","done":true, + "outcome_tag":"done","outcome":"Refunded the missing 50%.","reason":"Fix shipped."}"#, + ) + .unwrap(); + assert!(j.retitle); + assert_eq!(j.title, "Fix voucher refund"); + assert!(j.done); + assert_eq!(j.normalized_tag(), "done"); + } + + #[test] + fn parses_fenced_json_with_prose() { + let reply = "Here is the result:\n```json\n{\"retitle\":false,\"title\":\"Keep me\",\ +\"done\":false,\"outcome_tag\":\"\",\"outcome\":\"\",\"reason\":\"still investigating\"}\n```\n"; + let j = parse_judgment(reply).unwrap(); + assert!(!j.retitle); + assert!(!j.done); + assert_eq!(j.reason, "still investigating"); + } + + #[test] + fn unknown_tag_falls_back_to_done() { + let j = FinalizeJudgment { + retitle: false, + title: String::new(), + done: true, + outcome_tag: "weird".into(), + outcome: String::new(), + reason: String::new(), + }; + assert_eq!(j.normalized_tag(), "done"); + } + + #[test] + fn should_apply_title_only_when_flagged_and_different() { + let mut j = FinalizeJudgment { + retitle: true, + title: "Good title".into(), + done: false, + outcome_tag: String::new(), + outcome: String::new(), + reason: String::new(), + }; + assert!(j.should_apply_title("#: 5")); + // Same title → no churn. + assert!(!j.should_apply_title("Good title")); + // Model says keep → never replace, even if different. + j.retitle = false; + assert!(!j.should_apply_title("#: 5")); + // Empty proposal → never replace. + j.retitle = true; + j.title = " ".into(); + assert!(!j.should_apply_title("#: 5")); + } + + #[test] + fn prompt_includes_title_and_history() { + let p = build_prompt( + "#: 5", + &["[open] #: 5".into(), "[decision] use SQL pack".into()], + ); + assert!(p.contains("Current title: #: 5")); + assert!(p.contains("[decision] use SQL pack")); + assert!(p.contains("\"done\"")); + } + + #[test] + fn judge_routes_through_backend() { + let backend = MockBackend( + r#"{"retitle":true,"title":"T","done":false,"outcome_tag":"","outcome":"","reason":"r"}"# + .into(), + ); + let j = judge("old", &["[open] old".into()], &backend).unwrap(); + assert_eq!(j.title, "T"); + assert!(!j.done); + } +} diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index b5e46fc..de685c8 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -55,6 +55,7 @@ pub mod db; pub mod dream; pub mod embed; pub mod event; +pub mod finalize; pub mod frontmatter; pub mod fts; pub mod llm; From 82a04030de43134fbbfe13f701755ff29c5b227d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sat, 13 Jun 2026 19:37:27 +0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(complete):=20finalize=20tasks=20?= =?UTF-8?q?=E2=80=94=20enrich,=20retitle,=20close-if-done,=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit complete now finalizes a legacy task: enrich its memory from the sessions it touched (task-scoped, no dream watermark), ask the model to judge a human title + outcome + whether it is clearly done, write a Rename event if the title is junk, and close it (Close event carrying the outcome) only when done. complete with no id finalizes every open task: a numbered list with event/session counts, exclude-by-number, confirm, then per-task finalize; undone tasks stay open and are listed. --quick skips enrich, --dry-run reports scope, --yes gates non-TTY batch. Close events now persist outcome/outcome_tag in meta and upsert restores them, so a recorded outcome survives a rebuild_state replay. claude-memory-b12 claude-memory-3ro Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tj-cli/src/main.rs | 434 ++++++++++++++++++++++++++++++++++++- crates/tj-cli/tests/cli.rs | 88 +++++++- crates/tj-core/src/db.rs | 41 ++++ 3 files changed, 548 insertions(+), 15 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index fc9639b..3894399 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -871,16 +871,24 @@ enum Commands { #[arg(long)] backend: Option, }, - /// Complete a task from its transcripts: re-read the sessions tied to a task - /// and append the decisions/findings the live capture missed. A friendly - /// alias for `dream --task `. MANUAL, one LLM call per session via the - /// chosen backend (free with `--backend ollama`). + /// Finalize a task: enrich its memory from the sessions it touched, fix a + /// junk auto-title, and close it IF the events clearly show it is done — + /// the model decides from the content. Omit the id to finalize every open + /// task in the project (batch, with a reviewable list). One LLM call per + /// session for enrich + one judge call per task, via the chosen backend + /// (free with `--backend ollama`). Complete { - /// The task id to complete. - task: String, - /// Show scope without calling the model or writing anything. + /// The task id to finalize. Omit to finalize all open tasks (batch). + task: Option, + /// Show scope and planned actions without calling the model or writing. #[arg(long)] dry_run: bool, + /// Skip the (heavy) enrich pass; judge/retitle/close from stored events only. + #[arg(long)] + quick: bool, + /// Required for batch finalize when stdin is not an interactive terminal. + #[arg(long)] + yes: bool, /// LLM backend override: claude-p (default) | anthropic | openai | ollama. #[arg(long)] backend: Option, @@ -2776,11 +2784,13 @@ fn main() -> Result<()> { Commands::Complete { task, dry_run, + quick, + yes, backend, - } => { - // "complete this task" = dream scoped to that task's sessions. - run_dream_op(None, Some(task), dry_run, None, backend.as_deref())?; - } + } => match task { + Some(id) => run_complete_single(&id, dry_run, quick, backend.as_deref())?, + None => run_complete_batch(dry_run, quick, yes, backend.as_deref())?, + }, Commands::Export { format, task, @@ -4082,6 +4092,408 @@ PATH; or pick one via --backend / TJ_BACKEND: anthropic, openai, ollama (free, l Ok(()) } +// --------------------------------------------------------------------------- +// complete / finalize: enrich a task's memory, fix a junk title, and close it +// if the events clearly show it is done. The model judges from content. +// --------------------------------------------------------------------------- + +/// What `finalize_one_task` did, for the caller to report. +#[derive(Default)] +struct FinalizeOutcome { + enriched: usize, + retitled: Option<(String, String)>, + closed: bool, + done: bool, + reason: String, + /// True when no LLM backend was available — nothing was judged or written. + skipped_no_backend: bool, +} + +/// Per-project handles threaded through the finalize helpers. +struct ProjectCtx<'a> { + conn: &'a rusqlite::Connection, + events_path: &'a std::path::Path, + project_hash: &'a str, + project_dir: Option<&'a std::path::Path>, +} + +/// The (session_id, BackfillInput) pairs for every session that touched +/// `task_id`. No watermark: finalize scopes to the task, not to "since the +/// last dream", so tasks can be finalized independently and repeatedly. +fn task_sessions( + events_path: &std::path::Path, + project_dir: &std::path::Path, + task_id: &str, +) -> anyhow::Result> { + let session_paths = tj_core::session::discovery::list_sessions(project_dir)?; + let scoped: Vec = session_paths + .into_iter() + .filter_map(|p| { + let mtime = std::fs::metadata(&p).ok()?.modified().ok()?; + Some(tj_core::dream::scope::SessionFile { path: p, mtime }) + }) + .collect(); + let in_scope = tj_core::dream::scope::in_scope(scoped, None, None); + build_dream_inputs(events_path, &in_scope, Some(task_id)) +} + +/// Enrich a single task from every session that touched it. Unlike `dream`, +/// this is task-scoped and does NOT read or advance the project-global dream +/// watermark. `dedup_guard` inside `run_dream` keeps re-runs from duplicating +/// events. Returns the number of events appended. +fn enrich_task( + conn: &rusqlite::Connection, + events_path: &std::path::Path, + project_hash: &str, + project_dir: &std::path::Path, + task_id: &str, + llm: Box, +) -> anyhow::Result { + let sessions = task_sessions(events_path, project_dir, task_id)?; + if sessions.is_empty() { + return Ok(0); + } + let run_id = ulid::Ulid::new().to_string(); + let dream_backend = tj_core::dream::llm_backend::LlmDreamBackend::new(llm); + let opts = tj_core::dream::DreamOptions { + project_hash: project_hash.to_string(), + dry_run: false, + }; + let report = + tj_core::dream::run_dream(conn, events_path, &opts, &dream_backend, sessions, &run_id)?; + Ok(report.events_backfilled) +} + +/// Current title for a task ("" if somehow unset). +fn task_title(conn: &rusqlite::Connection, task_id: &str) -> anyhow::Result { + let mut stmt = conn.prepare("SELECT title FROM tasks WHERE task_id=?1")?; + let mut rows = stmt.query(rusqlite::params![task_id])?; + Ok(match rows.next()? { + Some(r) => r.get::<_, String>(0)?, + None => String::new(), + }) +} + +/// A task's events as `[type] text` lines, oldest first, capped so the judge +/// prompt stays bounded on very long tasks. +fn task_event_lines(conn: &rusqlite::Connection, task_id: &str) -> anyhow::Result> { + const MAX_LINES: usize = 150; + let mut stmt = conn.prepare( + "SELECT ei.type, sf.text FROM events_index ei + LEFT JOIN search_fts sf ON sf.event_id = ei.event_id + WHERE ei.task_id=?1 ORDER BY ei.timestamp ASC", + )?; + let all: Vec = stmt + .query_map(rusqlite::params![task_id], |r| { + let ty: String = r.get(0)?; + let txt: Option = r.get(1)?; + let one = txt + .unwrap_or_default() + .replace('\n', " ") + .chars() + .take(200) + .collect::(); + Ok(format!("[{ty}] {one}")) + })? + .collect::>()?; + let start = all.len().saturating_sub(MAX_LINES); + Ok(all[start..].to_vec()) +} + +/// Finalize one task: enrich → judge → retitle-if-junk → close-if-done. +/// Writes Rename/Close events (which carry their metadata so a rebuild keeps +/// them) and refreshes the index. Reports what happened via `FinalizeOutcome`. +fn finalize_one_task( + ctx: &ProjectCtx<'_>, + task_id: &str, + quick: bool, + dry_run: bool, + backend: Option<&str>, +) -> anyhow::Result { + let mut out = FinalizeOutcome::default(); + let conn = ctx.conn; + let events_path = ctx.events_path; + let project_hash = ctx.project_hash; + + // 1. Enrich (unless quick / dry-run) — needs sessions and a backend. + if !quick && !dry_run { + if let Some(dir) = ctx.project_dir { + if let Some(llm) = tj_core::llm::backend_from_env(backend)? { + out.enriched = enrich_task(conn, events_path, project_hash, dir, task_id, llm)?; + tj_core::db::ingest_new_events(conn, events_path, project_hash)?; + } + } + } + + // 2. Gather the (now enriched) title + history. + let title = task_title(conn, task_id)?; + let lines = task_event_lines(conn, task_id)?; + + if dry_run { + let sessions = match ctx.project_dir { + Some(dir) => task_sessions(events_path, dir, task_id)?.len(), + None => 0, + }; + println!( + "complete (dry-run) {task_id}: {} event(s), {sessions} session(s) to enrich, title={title:?}", + lines.len() + ); + return Ok(out); + } + + // 3. Judge — essential, so a missing backend stops here. + let Some(judge_backend) = tj_core::llm::backend_from_env(backend)? else { + out.skipped_no_backend = true; + return Ok(out); + }; + let j = tj_core::finalize::judge(&title, &lines, judge_backend.as_ref())?; + out.done = j.done; + out.reason = j.reason.clone(); + + let mut writer = tj_core::storage::JsonlWriter::open(events_path)?; + + // 4. Retitle only when the model flagged the current title as junk. + if j.should_apply_title(&title) { + let mut ev = tj_core::event::Event::new( + task_id, + tj_core::event::EventType::Rename, + tj_core::event::Author::Agent, + tj_core::event::Source::Cli, + j.title.clone(), + ); + ev.meta = serde_json::json!({ "title": j.title, "was": title }); + writer.append(&ev)?; + out.retitled = Some((title.clone(), j.title.clone())); + } + + // 5. Close only when the events clearly show the task is done. The + // outcome rides on the event meta so it survives a rebuild_state replay. + if j.done { + let reason = if j.reason.is_empty() { + "(finalized)".to_string() + } else { + j.reason.clone() + }; + let mut ev = tj_core::event::Event::new( + task_id, + tj_core::event::EventType::Close, + tj_core::event::Author::Agent, + tj_core::event::Source::Cli, + reason, + ); + ev.meta = serde_json::json!({ + "outcome": j.outcome, + "outcome_tag": j.normalized_tag(), + "reason": j.reason, + }); + writer.append(&ev)?; + out.closed = true; + } + + writer.flush_durable()?; + tj_core::db::ingest_new_events(conn, events_path, project_hash)?; + Ok(out) +} + +/// Human-readable one-liner for a finalize result. +fn print_finalize_outcome(task_id: &str, out: &FinalizeOutcome) { + if out.skipped_no_backend { + println!( + "complete {task_id}: no usable LLM backend. Default `claude-p` needs Claude Code on \ +PATH; or pick one via --backend / TJ_BACKEND: anthropic, openai, ollama (free, local)." + ); + return; + } + let mut parts = Vec::new(); + if out.enriched > 0 { + parts.push(format!("{} event(s) backfilled", out.enriched)); + } + if let Some((old, new)) = &out.retitled { + parts.push(format!("retitled {old:?} → {new:?}")); + } + if out.closed { + parts.push("closed".to_string()); + } else { + let why = if out.reason.is_empty() { + "not clearly done".to_string() + } else { + out.reason.clone() + }; + parts.push(format!("left open ({why})")); + } + if parts.is_empty() { + parts.push("no change".to_string()); + } + println!("complete {task_id}: {}", parts.join("; ")); +} + +/// `complete ` — finalize a single task. +fn run_complete_single( + task_id: &str, + dry_run: bool, + quick: bool, + backend: Option<&str>, +) -> anyhow::Result<()> { + let cwd = std::env::current_dir()?; + let project_hash = tj_core::project_hash::from_path(&cwd)?; + let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let state_path = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + let conn = tj_core::db::open(&state_path)?; + if events_path.exists() { + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + } + if !tj_core::db::task_exists(&conn, task_id)? { + anyhow::bail!("task not found: {task_id}"); + } + let project_dir = tj_core::session::discovery::find_project_dir(&cwd)?; + let ctx = ProjectCtx { + conn: &conn, + events_path: &events_path, + project_hash: &project_hash, + project_dir: project_dir.as_deref(), + }; + let out = finalize_one_task(&ctx, task_id, quick, dry_run, backend)?; + print_finalize_outcome(task_id, &out); + Ok(()) +} + +/// `complete` (no id) — finalize every open task, with a reviewable list the +/// user can prune before confirming. Refuses without a TTY unless `--yes`. +fn run_complete_batch( + dry_run: bool, + quick: bool, + yes: bool, + backend: Option<&str>, +) -> anyhow::Result<()> { + use std::io::IsTerminal; + + let cwd = std::env::current_dir()?; + let project_hash = tj_core::project_hash::from_path(&cwd)?; + let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let state_path = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + let conn = tj_core::db::open(&state_path)?; + if events_path.exists() { + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + } + + let mut stmt = conn.prepare( + "SELECT task_id, title FROM tasks WHERE status='open' ORDER BY last_event_at DESC", + )?; + let open: Vec<(String, String)> = stmt + .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? + .collect::>()?; + drop(stmt); + if open.is_empty() { + println!("complete: no open tasks in this project."); + return Ok(()); + } + + let project_dir = tj_core::session::discovery::find_project_dir(&cwd)?; + + // Show the numbered list with event/session counts so the user can judge + // what to keep before anything is touched. + println!("Open tasks ({}):", open.len()); + for (i, (id, title)) in open.iter().enumerate() { + let events: i64 = conn.query_row( + "SELECT COUNT(*) FROM events_index WHERE task_id=?1", + rusqlite::params![id], + |r| r.get(0), + )?; + let sessions = match project_dir.as_deref() { + Some(dir) => task_sessions(&events_path, dir, id)?.len(), + None => 0, + }; + println!(" {}. {id} [{events} ev, {sessions} sess] {title}", i + 1); + } + + let ctx = ProjectCtx { + conn: &conn, + events_path: &events_path, + project_hash: &project_hash, + project_dir: project_dir.as_deref(), + }; + + // Dry-run: report planned scope per task and stop — no prompts, no writes. + if dry_run { + println!(); + for (id, _) in &open { + finalize_one_task(&ctx, id, quick, true, backend)?; + } + return Ok(()); + } + + let interactive = std::io::stdin().is_terminal(); + if !interactive && !yes { + anyhow::bail!( + "batch complete needs an interactive terminal to confirm; pass --yes to run it non-interactively" + ); + } + + // Let the user exclude tasks, then confirm. + let mut excluded: std::collections::HashSet = std::collections::HashSet::new(); + if interactive && !yes { + println!("\nNumbers to EXCLUDE (space/comma separated), or Enter to finalize all:"); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + for tok in buf.split(|c: char| c.is_whitespace() || c == ',') { + if let Ok(n) = tok.trim().parse::() { + if n >= 1 && n <= open.len() { + excluded.insert(n - 1); + } + } + } + } + let targets: Vec<&(String, String)> = open + .iter() + .enumerate() + .filter(|(i, _)| !excluded.contains(i)) + .map(|(_, r)| r) + .collect(); + if targets.is_empty() { + println!("complete: nothing selected."); + return Ok(()); + } + if interactive && !yes { + println!( + "\nWill finalize {} task(s){}. Proceed? [y/N]", + targets.len(), + if quick { " (quick: no enrich)" } else { "" } + ); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + if !matches!(buf.trim().to_lowercase().as_str(), "y" | "yes") { + println!("aborted."); + return Ok(()); + } + } + + let mut left_open: Vec<(String, String)> = Vec::new(); + for (id, _) in &targets { + let out = finalize_one_task(&ctx, id, quick, false, backend)?; + print_finalize_outcome(id, &out); + if out.skipped_no_backend { + println!("complete: stopping batch — no LLM backend available."); + return Ok(()); + } + if !out.closed { + left_open.push((id.clone(), out.reason.clone())); + } + } + + if !left_open.is_empty() { + println!("\nLeft open ({}):", left_open.len()); + for (id, reason) in &left_open { + let why = if reason.is_empty() { + "not clearly done" + } else { + reason + }; + println!(" {id} — {why}"); + } + } + Ok(()) +} + /// Manual consolidation: read this project's recurring decisions/constraints, /// distil them into durable facts via one LLM call through the chosen backend, /// and store the facts as events in a per-project conventions task. Skips diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index e8c5afd..db7d70c 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -5451,16 +5451,96 @@ fn capture_off_marker_no_ops_ingest_hook_capture() { #[test] fn complete_command_runs_and_skips_cleanly_without_sessions() { - // `complete ` is a friendly alias for `dream --task`; with no Claude - // Code sessions for the project it exits cleanly (no model call). + // `complete --dry-run` reports scope without calling the model or + // writing anything. With no Claude Code sessions it shows 0 to enrich. let dir = assert_fs::TempDir::new().unwrap(); let proj = assert_fs::TempDir::new().unwrap(); + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["create", "Finalize me", "--goal", "ship it"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["complete", &task_id, "--dry-run"]) + .assert() + .success() + .stdout(contains("complete (dry-run)")) + .stdout(contains("session(s) to enrich")); +} + +#[test] +fn complete_unknown_task_errors() { + // A non-existent id is a hard error, not a silent no-op. + let dir = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["complete", "tj-nope", "--dry-run"]) + .assert() + .failure() + .stderr(contains("task not found")); +} + +#[test] +fn complete_batch_refuses_without_tty_or_yes() { + // No id = batch. Non-interactive stdin (test harness) without --yes must + // refuse rather than mass-close tasks unattended. + let dir = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["create", "Batch me", "--goal", "g"]) + .assert() + .success(); + + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["complete"]) + .assert() + .failure() + .stderr(contains("interactive terminal")); +} + +#[test] +fn complete_batch_dry_run_lists_open_tasks() { + // Batch dry-run lists open tasks and reports per-task scope, no prompts. + let dir = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["create", "Listed task", "--goal", "g"]) + .assert() + .success(); + Command::cargo_bin("task-journal") .unwrap() .current_dir(proj.path()) .env("XDG_DATA_HOME", dir.path()) - .args(["complete", "tj-x", "--dry-run"]) + .args(["complete", "--dry-run"]) .assert() .success() - .stdout(contains("dream")); + .stdout(contains("Open tasks (")) + .stdout(contains("Listed task")); } diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index cb8df0f..389bda6 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -264,6 +264,16 @@ pub fn upsert_task_from_event( "UPDATE tasks SET status='closed', closed_at=?2, last_event_at=?2 WHERE task_id=?1", rusqlite::params![event.task_id, event.timestamp], )?; + // Restore closure metadata from the event so the recorded outcome + // survives a full rebuild_state replay — the tasks row is rebuilt + // from events, and set_task_outcome's direct DB write would be lost. + if let Some(outcome) = event.meta.get("outcome").and_then(|v| v.as_str()) { + let tag = event.meta.get("outcome_tag").and_then(|v| v.as_str()); + conn.execute( + "UPDATE tasks SET outcome=?2, outcome_tag=?3 WHERE task_id=?1", + rusqlite::params![event.task_id, outcome, tag], + )?; + } } EventType::Reopen => { conn.execute( @@ -1366,6 +1376,37 @@ mod tests { ); } + #[test] + fn close_event_restores_outcome_from_meta() { + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + let ph = "feedfacefeedface"; + + let open_ev = make_open_event("tj-cl", "T"); + upsert_task_from_event(&conn, &open_ev, ph).unwrap(); + + let mut close = crate::event::Event::new( + "tj-cl", + crate::event::EventType::Close, + crate::event::Author::Agent, + crate::event::Source::Cli, + "done".into(), + ); + close.meta = serde_json::json!({"outcome": "Shipped the fix.", "outcome_tag": "done"}); + upsert_task_from_event(&conn, &close, ph).unwrap(); + + let (status, outcome, tag): (String, Option, Option) = conn + .query_row( + "SELECT status, outcome, outcome_tag FROM tasks WHERE task_id='tj-cl'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(status, "closed"); + assert_eq!(outcome.as_deref(), Some("Shipped the fix.")); + assert_eq!(tag.as_deref(), Some("done")); + } + #[test] fn fresh_db_runs_all_migrations() { let d = TempDir::new().unwrap(); From 52b0455204a0e54683a02ffe2dc1dc54773f62c3 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sat, 13 Jun 2026 19:41:56 +0400 Subject: [PATCH 4/5] test(complete): end-to-end finalize via fake claude backend Unix-only integration test drives the real claude-p path with a fake `claude` on PATH returning a canned judgment: a junk title (#: 5) is retitled and the task is closed with a persisted outcome, verified through `pack`. Cross-platform logic stays covered by finalize.rs units. claude-memory-jky Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/tj-cli/tests/cli.rs | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index db7d70c..c90699c 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -5544,3 +5544,93 @@ fn complete_batch_dry_run_lists_open_tasks() { .stdout(contains("Open tasks (")) .stdout(contains("Listed task")); } + +/// End-to-end finalize through the real claude-p backend path, with a fake +/// `claude` on PATH returning a canned judgment. Proves the wiring: junk +/// title → Rename, done verdict → Close with a persisted outcome. Unix-only +/// (shell-script stub); the logic itself is covered cross-platform by the +/// finalize.rs unit tests. +#[cfg(unix)] +#[test] +fn complete_quick_retitles_and_closes_via_fake_backend() { + use std::os::unix::fs::PermissionsExt; + + let dir = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + let bindir = assert_fs::TempDir::new().unwrap(); + + // The judgment the fake model "returns" — wrapped in claude's JSON envelope + // whose `result` field is the finalize JSON string. + let envelope = serde_json::json!({ + "is_error": false, + "result": serde_json::json!({ + "retitle": true, + "title": "Voucher refund: paid 100% but got 50%", + "done": true, + "outcome_tag": "done", + "outcome": "Refunded the missing half to the customer.", + "reason": "Fix shipped and verified." + }).to_string() + }) + .to_string(); + let resp_path = bindir.path().join("resp.json"); + std::fs::write(&resp_path, &envelope).unwrap(); + + // Fake `claude`: answer --version, drain stdin, print the envelope. + let claude = bindir.path().join("claude"); + std::fs::write( + &claude, + format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo fake; exit 0; fi\ncat >/dev/null\ncat {}\n", + resp_path.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path_env = format!( + "{}:{}", + bindir.path().display(), + std::env::var("PATH").unwrap_or_default() + ); + + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["create", "#: 5"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + // --quick: skip enrich (no sessions), exercise judge → retitle → close. + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .env("PATH", &path_env) + .env_remove("ANTHROPIC_API_KEY") + .args(["complete", &task_id, "--quick"]) + .assert() + .success() + .stdout(contains("retitled")) + .stdout(contains("closed")); + + // The task now carries the human title, closed status, and the outcome. + Command::cargo_bin("task-journal") + .unwrap() + .current_dir(proj.path()) + .env("XDG_DATA_HOME", dir.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("Voucher refund: paid 100% but got 50%")) + .stdout(contains("status: closed")) + .stdout(contains("Refunded the missing half")); +} From 1389beb09b4f88874a8e5d7998dd493e757ef2c9 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sat, 13 Jun 2026 19:43:09 +0400 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=200.22.0=20=E2=80=94=20comp?= =?UTF-8?q?lete=20finalizes=20tasks=20(enrich+retitle+close+batch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/tj-cli/Cargo.toml | 2 +- crates/tj-mcp/Cargo.toml | 2 +- plugin/.claude-plugin/plugin.json | 2 +- 6 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b626ab5..2239cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.22.0] - 2026-06-13 + +### Added +- **`complete` finalizes a task.** `task-journal complete ` now brings a + legacy task to the shape a live-journaled one would have had: it enriches the + task's memory from the sessions it touched (task-scoped — no dream watermark, + so tasks finalize independently), asks the model to judge a human-readable + title, a one-sentence outcome, and whether the events clearly show the task is + done, then writes a `Rename` event when the auto-title is junk and a `Close` + event **only when done** — the model decides from content; unclear tasks stay + open. Artifacts are picked up automatically as enriched events are indexed. +- **Batch finalize.** `task-journal complete` with no id finalizes every open + task: it prints a numbered list (id · event/session counts · title), lets you + exclude tasks by number, shows the count and asks to confirm, then finalizes + the rest; tasks judged not-done are left open and listed at the end. `--quick` + skips the heavy enrich pass, `--dry-run` reports scope without calling the + model, and `--yes` is required to run the batch without an interactive + terminal (so a hook can't mass-close tasks unattended). +- **`Rename` event type** — updates a task's title on replay (latest wins), + letting `complete` fix junk auto-titles without mutating the append-only log. + +### Fixed +- **Close outcome survives a rebuild.** `Close` events now carry their + `outcome`/`outcome_tag` in metadata and `rebuild_state` restores them, so a + recorded outcome is no longer lost the next time the SQLite state is rebuilt + from the JSONL log. + ## [0.21.0] - 2026-06-13 ### Added diff --git a/Cargo.lock b/Cargo.lock index f25c8fa..3fd5b0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "assert_cmd", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "chrono", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.21.1" +version = "0.22.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 6e72647..73d035f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.21.1" +version = "0.22.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index a817f0d..78e29ad 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -23,7 +23,7 @@ default = ["embed"] embed = ["tj-core/embed"] [dependencies] -tj-core = { package = "task-journal-core", version = "0.21.1", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.22.0", path = "../tj-core", default-features = false } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 21cdf07..42fe43e 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -17,7 +17,7 @@ path = "src/main.rs" [dependencies] # Lean: the MCP server doesn't embed yet, so it skips the model2vec backend. -tj-core = { package = "task-journal-core", version = "0.21.1", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.22.0", path = "../tj-core", default-features = false } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 2d892a8..5ee8eb8 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.21.1", + "version": "0.22.0", "description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.", "author": { "name": "Mher Shahinyan"