diff --git a/CHANGELOG.md b/CHANGELOG.md index 75eceee..9df63b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.23.0] - 2026-06-13 +## [0.24.0] - 2026-06-13 + +### Added +- **`complete` reports tokens spent and saved.** Each finalize now prints what + it cost and what it compresses: `complete tj-x: … | spent 1.5k tok ($0.0012) · + saved ~88k→1.5k tok (59×)`. **Spent** is exact, pulled from the backend's own + usage report (the `claude -p` JSON envelope's `usage`/`total_cost_usd`, + Anthropic/OpenAI `usage`), summed across the judge call and any `--enrich` + calls. **Saved** is an estimate of memory compression — the raw transcript + size of the task's sessions vs its compact pack (≈ chars/4). A batch run ends + with a `Totals across N task(s):` line. Backends expose usage via a new + `LlmBackend::complete_usage` method (default: no usage), so custom backends + keep working unchanged. Finalize, retuned after running `complete` on real 12-session tasks: the fast, reliable judge-only path is now the default, and the slow session-enrich pass is diff --git a/Cargo.lock b/Cargo.lock index bec2953..f55b875 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "assert_cmd", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "chrono", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.23.0" +version = "0.24.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index acf8fae..f292469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.23.0" +version = "0.24.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index 6f3ce8b..ef5f034 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.23.0", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.24.0", path = "../tj-core", default-features = false } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 28d49a0..eeff54b 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -4108,6 +4108,89 @@ struct FinalizeOutcome { reason: String, /// True when no LLM backend was available — nothing was judged or written. skipped_no_backend: bool, + /// Exact token usage spent on this task (judge + any enrich calls). + spent: tj_core::llm::LlmUsage, + /// Estimated memory compression: raw session tokens → compact pack tokens. + saved: Option, +} + +/// Rough memory-compression estimate for a finalized task (≈ chars / 4). +#[derive(Default, Clone, Copy)] +struct Savings { + raw_tokens: u64, + pack_tokens: u64, +} + +/// ~tokens from a char count (a rough 4-chars-per-token estimate — enough for +/// an order-of-magnitude "how much memory this compresses" signal). +fn est_tokens(chars: usize) -> u64 { + (chars as u64).div_ceil(4) +} + +/// Estimate how much raw session material a task's compact pack stands in for: +/// the summed transcript size of the sessions it touched vs the pack size. +/// `None` when sessions aren't reachable (no project dir). +fn compute_savings( + conn: &rusqlite::Connection, + events_path: &std::path::Path, + project_dir: Option<&std::path::Path>, + task_id: &str, +) -> Option { + let dir = project_dir?; + let sessions = task_sessions(events_path, dir, task_id).ok()?; + if sessions.is_empty() { + return None; + } + let raw_chars: usize = sessions.iter().map(|(_, inp)| inp.transcript.len()).sum(); + let pack = tj_core::pack::assemble(conn, task_id, tj_core::pack::PackMode::Compact).ok()?; + Some(Savings { + raw_tokens: est_tokens(raw_chars), + pack_tokens: est_tokens(pack.text.len()), + }) +} + +/// Format a token count compactly: 980 → "980", 3_240 → "3.2k", 88_000 → "88k". +fn fmt_tokens(n: u64) -> String { + if n < 1_000 { + n.to_string() + } else if n < 100_000 { + format!("{:.1}k", n as f64 / 1_000.0) + } else { + format!("{}k", n / 1_000) + } +} + +/// Human spent/saved suffix for a finalize line, e.g. +/// " | spent 3.2k tok ($0.0012) · saved ~88k→1.5k tok (59×)". +fn stats_suffix(spent: &tj_core::llm::LlmUsage, saved: &Option) -> String { + let mut parts = Vec::new(); + if spent.total_tokens() > 0 { + let cost = match spent.cost_usd { + Some(c) if c > 0.0 => format!(" (${c:.4})"), + _ => String::new(), + }; + parts.push(format!( + "spent {} tok{}", + fmt_tokens(spent.total_tokens()), + cost + )); + } + if let Some(s) = saved { + if s.pack_tokens > 0 && s.raw_tokens > s.pack_tokens { + let factor = s.raw_tokens as f64 / s.pack_tokens as f64; + parts.push(format!( + "saved ~{}→{} tok ({:.0}×)", + fmt_tokens(s.raw_tokens), + fmt_tokens(s.pack_tokens), + factor + )); + } + } + if parts.is_empty() { + String::new() + } else { + format!(" | {}", parts.join(" · ")) + } } /// Per-project handles threaded through the finalize helpers. @@ -4149,10 +4232,10 @@ fn enrich_task( project_dir: &std::path::Path, task_id: &str, llm: Box, -) -> anyhow::Result { +) -> anyhow::Result<(usize, tj_core::llm::LlmUsage)> { let sessions = task_sessions(events_path, project_dir, task_id)?; if sessions.is_empty() { - return Ok(0); + return Ok((0, tj_core::llm::LlmUsage::default())); } // Enrich is the slow part — one (or more, for big transcripts) `claude -p` // call per session. Announce it so a multi-minute run doesn't look hung; @@ -4170,7 +4253,7 @@ fn enrich_task( }; let report = tj_core::dream::run_dream(conn, events_path, &opts, &dream_backend, sessions, &run_id)?; - Ok(report.events_backfilled) + Ok((report.events_backfilled, dream_backend.usage())) } /// Current title for a task ("" if somehow unset). @@ -4229,7 +4312,10 @@ fn finalize_one_task( if enrich && !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)?; + let (n, enrich_usage) = + enrich_task(conn, events_path, project_hash, dir, task_id, llm)?; + out.enriched = n; + out.spent.add(enrich_usage); tj_core::db::ingest_new_events(conn, events_path, project_hash)?; } } @@ -4256,7 +4342,8 @@ fn finalize_one_task( out.skipped_no_backend = true; return Ok(out); }; - let j = tj_core::finalize::judge(&title, &lines, judge_backend.as_ref())?; + let (j, judge_usage) = tj_core::finalize::judge(&title, &lines, judge_backend.as_ref())?; + out.spent.add(judge_usage); out.done = j.done; out.reason = j.reason.clone(); @@ -4302,6 +4389,9 @@ fn finalize_one_task( writer.flush_durable()?; tj_core::db::ingest_new_events(conn, events_path, project_hash)?; + + // 6. Estimate the memory compression this finalize represents. + out.saved = compute_savings(conn, events_path, ctx.project_dir, task_id); Ok(out) } @@ -4334,7 +4424,11 @@ PATH; or pick one via --backend / TJ_BACKEND: anthropic, openai, ollama (free, l if parts.is_empty() { parts.push("no change".to_string()); } - println!("complete {task_id}: {}", parts.join("; ")); + println!( + "complete {task_id}: {}{}", + parts.join("; "), + stats_suffix(&out.spent, &out.saved) + ); } /// `complete ` — finalize a single task. @@ -4482,6 +4576,9 @@ fn run_complete_batch( } let mut left_open: Vec<(String, String)> = Vec::new(); + let mut total_spent = tj_core::llm::LlmUsage::default(); + let mut total_saved = Savings::default(); + let mut done_count = 0usize; for (id, _) in &targets { let out = finalize_one_task(&ctx, id, enrich, false, backend)?; print_finalize_outcome(id, &out); @@ -4489,11 +4586,25 @@ fn run_complete_batch( println!("complete: stopping batch — no LLM backend available."); return Ok(()); } + total_spent.add(out.spent); + if let Some(s) = out.saved { + total_saved.raw_tokens += s.raw_tokens; + total_saved.pack_tokens += s.pack_tokens; + } + done_count += 1; if !out.closed { left_open.push((id.clone(), out.reason.clone())); } } + let totals = stats_suffix(&total_spent, &Some(total_saved)); + if !totals.is_empty() { + println!( + "\nTotals across {done_count} task(s): {}", + totals.trim_start_matches(" | ") + ); + } + if !left_open.is_empty() { println!("\nLeft open ({}):", left_open.len()); for (id, reason) in &left_open { @@ -5551,6 +5662,43 @@ mod inline_tests { // declared before this module begins. use super::*; + #[test] + fn fmt_tokens_scales_units() { + assert_eq!(fmt_tokens(980), "980"); + assert_eq!(fmt_tokens(1_500), "1.5k"); + assert_eq!(fmt_tokens(88_000), "88.0k"); + assert_eq!(fmt_tokens(204_000), "204k"); + } + + #[test] + fn stats_suffix_shows_spent_and_saved() { + let spent = tj_core::llm::LlmUsage { + input_tokens: 1200, + output_tokens: 300, + cost_usd: Some(0.0012), + }; + let saved = Some(Savings { + raw_tokens: 90_000, + pack_tokens: 1_500, + }); + let s = stats_suffix(&spent, &saved); + assert!(s.contains("spent 1.5k tok ($0.0012)"), "{s}"); + assert!(s.contains("saved ~90.0k→1.5k tok (60×)"), "{s}"); + } + + #[test] + fn stats_suffix_empty_when_nothing_to_report() { + let spent = tj_core::llm::LlmUsage::default(); + assert_eq!(stats_suffix(&spent, &None), ""); + // Cost omitted when zero/None; tokens still shown. + let spent = tj_core::llm::LlmUsage { + input_tokens: 500, + output_tokens: 0, + cost_usd: None, + }; + assert_eq!(stats_suffix(&spent, &None), " | spent 500 tok"); + } + #[test] fn nudge_escalates_only_for_substantial_thin_sessions() { // Small session → never escalate, regardless of capture. diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 645b804..c55aff0 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -5563,6 +5563,8 @@ fn complete_retitles_and_closes_via_fake_backend() { // whose `result` field is the finalize JSON string. let envelope = serde_json::json!({ "is_error": false, + "usage": {"input_tokens": 1200, "output_tokens": 300}, + "total_cost_usd": 0.0012, "result": serde_json::json!({ "retitle": true, "title": "Voucher refund: paid 100% but got 50%", @@ -5619,6 +5621,7 @@ fn complete_retitles_and_closes_via_fake_backend() { .args(["complete", &task_id]) .assert() .success() + .stdout(contains("spent 1.5k tok ($0.0012)")) .stdout(contains("retitled")) .stdout(contains("closed")); diff --git a/crates/tj-core/src/classifier/agent_sdk.rs b/crates/tj-core/src/classifier/agent_sdk.rs index 239489d..69c5e07 100644 --- a/crates/tj-core/src/classifier/agent_sdk.rs +++ b/crates/tj-core/src/classifier/agent_sdk.rs @@ -236,9 +236,9 @@ impl ClaudeCliClassifier { } } -/// The JSON wrapper emitted by `claude --output-format json`. We only need the -/// error flag and the `result` string (the model's verdict text); the rest of -/// the envelope (usage, cost, timings) is ignored. +/// The JSON wrapper emitted by `claude --output-format json`. We read the error +/// flag, the `result` string (the model's verdict text), and the usage/cost so +/// callers can report what a call actually consumed. #[derive(serde::Deserialize)] struct CliEnvelope { #[serde(default)] @@ -247,6 +247,22 @@ struct CliEnvelope { result: Option, #[serde(default)] subtype: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + total_cost_usd: Option, +} + +#[derive(serde::Deserialize, Default)] +struct EnvelopeUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, + #[serde(default)] + cache_creation_input_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, } impl Classifier for ClaudeCliClassifier { @@ -266,6 +282,16 @@ pub fn run_claude_json( model: &str, prompt: &str, ) -> anyhow::Result { + run_claude_json_usage(runner, model, prompt).map(|(text, _)| text) +} + +/// Like [`run_claude_json`] but also returns the envelope's reported token +/// usage and cost (zeros when the envelope omits them). +pub fn run_claude_json_usage( + runner: &dyn CommandRunner, + model: &str, + prompt: &str, +) -> anyhow::Result<(String, crate::llm::LlmUsage)> { let stdout = runner.run(model, prompt)?; let envelope: CliEnvelope = serde_json::from_str(stdout.trim()).with_context(|| { format!( @@ -279,9 +305,17 @@ pub fn run_claude_json( envelope.subtype.as_deref().unwrap_or("unknown") )); } - envelope + let u = envelope.usage.unwrap_or_default(); + let usage = crate::llm::LlmUsage { + // Count cache reads/writes as input so the total reflects real context. + input_tokens: u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens, + output_tokens: u.output_tokens, + cost_usd: envelope.total_cost_usd, + }; + let result = envelope .result - .ok_or_else(|| anyhow!("claude json wrapper had no `result` field")) + .ok_or_else(|| anyhow!("claude json wrapper had no `result` field"))?; + Ok((result, usage)) } /// Probe whether `claude` resolves on PATH and runs. Cheap (`--version` does diff --git a/crates/tj-core/src/dream/llm_backend.rs b/crates/tj-core/src/dream/llm_backend.rs index 11e1042..777ab4c 100644 --- a/crates/tj-core/src/dream/llm_backend.rs +++ b/crates/tj-core/src/dream/llm_backend.rs @@ -12,16 +12,26 @@ use crate::llm::LlmBackend; /// run one completion, parse the JSON array of missed events. pub struct LlmDreamBackend { llm: Box, + /// Token usage accumulated across all chunk calls this backend made. + usage: std::sync::Mutex, } impl LlmDreamBackend { pub fn new(llm: Box) -> Self { - Self { llm } + Self { + llm, + usage: std::sync::Mutex::new(crate::llm::LlmUsage::default()), + } } pub fn backend_name(&self) -> &'static str { self.llm.name() } + + /// Total token usage this backend reported across every backfill chunk. + pub fn usage(&self) -> crate::llm::LlmUsage { + *self.usage.lock().unwrap() + } } /// Max transcript characters fed to the model in one call. The hard wall is @@ -47,13 +57,17 @@ impl DreamBackend for LlmDreamBackend { // (model continued the transcript dialogue) — is skipped, never // aborting the finalize. A genuinely broken backend still surfaces // at the judge step, which has its own (small, always-sized) call. - match self - .llm - .complete(&prompt, 1024) - .and_then(|text| parse_backfill_json(&text)) - { - Ok(evs) => out.extend(evs), - Err(e) => tracing::warn!(error = %e, "dream backfill: skipping chunk"), + match self.llm.complete_usage(&prompt, 1024) { + Ok((text, usage)) => { + self.usage.lock().unwrap().add(usage); + match parse_backfill_json(&text) { + Ok(evs) => out.extend(evs), + Err(e) => { + tracing::warn!(error = %e, "dream backfill: skipping unparseable chunk") + } + } + } + Err(e) => tracing::warn!(error = %e, "dream backfill: skipping failed chunk"), } } Ok(out) diff --git a/crates/tj-core/src/finalize.rs b/crates/tj-core/src/finalize.rs index b14cbe4..94cdec7 100644 --- a/crates/tj-core/src/finalize.rs +++ b/crates/tj-core/src/finalize.rs @@ -99,15 +99,16 @@ pub fn parse_judgment(text: &str) -> anyhow::Result { .with_context(|| format!("finalize JSON parse failed; got: {json_str}")) } -/// One judge call: prompt → model → parsed judgment. +/// One judge call: prompt → model → parsed judgment, with the token usage the +/// backend reported for the call. pub fn judge( current_title: &str, event_lines: &[String], backend: &dyn LlmBackend, -) -> anyhow::Result { +) -> anyhow::Result<(FinalizeJudgment, crate::llm::LlmUsage)> { let prompt = build_prompt(current_title, event_lines); - let reply = backend.complete(&prompt, 512)?; - parse_judgment(&reply) + let (reply, usage) = backend.complete_usage(&prompt, 512)?; + Ok((parse_judgment(&reply)?, usage)) } #[cfg(test)] @@ -199,7 +200,7 @@ mod tests { r#"{"retitle":true,"title":"T","done":false,"outcome_tag":"","outcome":"","reason":"r"}"# .into(), ); - let j = judge("old", &["[open] old".into()], &backend).unwrap(); + let (j, _usage) = judge("old", &["[open] old".into()], &backend).unwrap(); assert_eq!(j.title, "T"); assert!(!j.done); } diff --git a/crates/tj-core/src/llm.rs b/crates/tj-core/src/llm.rs index baacd0e..f4b1b72 100644 --- a/crates/tj-core/src/llm.rs +++ b/crates/tj-core/src/llm.rs @@ -22,11 +22,44 @@ use anyhow::{anyhow, Context}; use serde::{Deserialize, Serialize}; use std::time::Duration; +/// Token usage reported by a backend for one call. `cost_usd` is `None` when +/// the backend doesn't report a price (most APIs report tokens, not dollars; +/// `claude -p` reports `total_cost_usd`, which is 0 under a subscription). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct LlmUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: Option, +} + +impl LlmUsage { + pub fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + } + + /// Accumulate another call's usage into this one. + pub fn add(&mut self, other: LlmUsage) { + self.input_tokens += other.input_tokens; + self.output_tokens += other.output_tokens; + self.cost_usd = match (self.cost_usd, other.cost_usd) { + (Some(a), Some(b)) => Some(a + b), + (a, None) => a, + (None, b) => b, + }; + } +} + /// One AI call: a prompt in, the model's text reply out. pub trait LlmBackend: Send + Sync { fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result; /// Stable label for logs / provenance. fn name(&self) -> &'static str; + /// Like [`complete`](Self::complete) but also reports token usage when the + /// backend exposes it. Default: run `complete` and report no usage, so + /// mocks and minimal backends need not implement it. + fn complete_usage(&self, prompt: &str, max_tokens: u32) -> anyhow::Result<(String, LlmUsage)> { + Ok((self.complete(prompt, max_tokens)?, LlmUsage::default())) + } } /// Resolve the backend from an explicit name (e.g. a `--backend` flag) or @@ -82,16 +115,19 @@ impl ClaudeCliBackend { } impl LlmBackend for ClaudeCliBackend { - fn complete(&self, prompt: &str, _max_tokens: u32) -> anyhow::Result { - crate::classifier::agent_sdk::run_claude_json( + fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result { + self.complete_usage(prompt, max_tokens).map(|(t, _)| t) + } + fn name(&self) -> &'static str { + "claude-p" + } + fn complete_usage(&self, prompt: &str, _max_tokens: u32) -> anyhow::Result<(String, LlmUsage)> { + crate::classifier::agent_sdk::run_claude_json_usage( &crate::classifier::agent_sdk::ClaudeBinaryStdinRunner, &self.model, prompt, ) } - fn name(&self) -> &'static str { - "claude-p" - } } // --------------------------------------------------------------------------- @@ -134,6 +170,15 @@ struct AnthropicMsg<'a> { #[derive(Deserialize)] struct AnthropicResp { content: Vec, + #[serde(default)] + usage: AnthropicUsage, +} +#[derive(Deserialize, Default)] +struct AnthropicUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, } #[derive(Deserialize)] struct AnthropicBlock { @@ -145,6 +190,12 @@ struct AnthropicBlock { impl LlmBackend for AnthropicBackend { fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result { + self.complete_usage(prompt, max_tokens).map(|(t, _)| t) + } + fn name(&self) -> &'static str { + "anthropic" + } + fn complete_usage(&self, prompt: &str, max_tokens: u32) -> anyhow::Result<(String, LlmUsage)> { let body = AnthropicReq { model: &self.model, max_tokens, @@ -162,14 +213,18 @@ impl LlmBackend for AnthropicBackend { .context("Anthropic API request failed")? .into_json() .context("decode Anthropic response")?; - resp.content + let usage = LlmUsage { + input_tokens: resp.usage.input_tokens, + output_tokens: resp.usage.output_tokens, + cost_usd: None, + }; + let text = resp + .content .iter() .find(|b| b.kind == "text") .map(|b| b.text.clone()) - .ok_or_else(|| anyhow!("no text content in Anthropic response")) - } - fn name(&self) -> &'static str { - "anthropic" + .ok_or_else(|| anyhow!("no text content in Anthropic response"))?; + Ok((text, usage)) } } @@ -218,6 +273,15 @@ struct OpenAiReq<'a> { #[derive(Deserialize)] struct OpenAiResp { choices: Vec, + #[serde(default)] + usage: OpenAiUsage, +} +#[derive(Deserialize, Default)] +struct OpenAiUsage { + #[serde(default)] + prompt_tokens: u64, + #[serde(default)] + completion_tokens: u64, } #[derive(Deserialize)] struct OpenAiChoice { @@ -231,6 +295,9 @@ struct OpenAiMsg { impl LlmBackend for OpenAiBackend { fn complete(&self, prompt: &str, max_tokens: u32) -> anyhow::Result { + self.complete_usage(prompt, max_tokens).map(|(t, _)| t) + } + fn complete_usage(&self, prompt: &str, max_tokens: u32) -> anyhow::Result<(String, LlmUsage)> { let body = OpenAiReq { model: &self.model, max_tokens, @@ -250,11 +317,18 @@ impl LlmBackend for OpenAiBackend { .with_context(|| format!("{} request failed", self.label))? .into_json() .context("decode OpenAI-compatible response")?; - resp.choices + let usage = LlmUsage { + input_tokens: resp.usage.prompt_tokens, + output_tokens: resp.usage.completion_tokens, + cost_usd: None, + }; + let text = resp + .choices .into_iter() .next() .map(|c| c.message.content) - .ok_or_else(|| anyhow!("no choices in {} response", self.label)) + .ok_or_else(|| anyhow!("no choices in {} response", self.label))?; + Ok((text, usage)) } fn name(&self) -> &'static str { self.label diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index ac459ec..a82d6f5 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.23.0", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.24.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 79f4891..2e4366f 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.23.0", + "version": "0.24.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"