From 1bf3428d120828af8fa10ba7e11207d617ce5dc2 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Wed, 17 Jun 2026 16:20:14 +0400 Subject: [PATCH 1/2] feat(artifacts): structured clickable links {kind,url,label} for the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Artifacts.links rendered as [label](url) (kind) in the pack. Fed by: - harvest at close: resolves repo web URL (gh repo view) and emits clickable PR / commit / branch links (a bare commit hash becomes a real link) - artifact_add: new MCP tool + `task-journal artifact-add` CLI command so the agent can attach a doc / deploy / dashboard. Stored as a finding event whose meta.artifacts is merged by index_event — no new storage. Flat token vectors unchanged (still power search / relatedness). Bump 0.27.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++ Cargo.toml | 2 +- crates/tj-cli/src/main.rs | 43 +++++++ crates/tj-cli/tests/cli.rs | 43 +++++++ crates/tj-core/src/artifacts.rs | 59 +++++++++ crates/tj-core/src/harvest.rs | 205 +++++++++++++++++++++++++------- crates/tj-core/src/pack.rs | 10 ++ crates/tj-mcp/src/main.rs | 57 +++++++++ 8 files changed, 393 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bd5b4..52cfba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.27.0] - 2026-06-17 + +### Added +- **Clickable, typed artifact links on the task card.** `Artifacts` gains a + `links: [{kind, url, label}]` field rendered in the pack as + `[label](url) (kind)`. Two sources feed it: + - **Auto at close** — the harvest now resolves the repo's web URL (`gh repo + view`) and emits ready-to-click links for the PR (labelled `PR #N`), the + commit (`…/commit/`), and the branch (`…/tree/`), so a bare + commit hash becomes a real link. + - **`artifact_add`** — a new MCP tool (and `task-journal artifact-add` CLI + command) lets the agent attach arbitrary references — a design doc, a + deploy/preview URL, a dashboard — as `artifact_add(task_id, kind, url, + label)`. Stored as a `finding` event whose `meta.artifacts` is merged by + `index_event`, so it surfaces on the card without new storage. + + The flat token vectors (commit_hashes, pr_urls, …) are unchanged and still + power artifact search / task relatedness. + ## [0.26.6] - 2026-06-17 ### Added diff --git a/Cargo.toml b/Cargo.toml index e83283e..729faeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.26.6" +version = "0.27.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 35682c6..8c390fe 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -727,6 +727,22 @@ enum Commands { #[arg(long)] outcome_tag: Option, }, + /// Attach a clickable, typed link to a task (doc, deploy, dashboard, + /// design, …). Renders under the pack's Artifacts as `[label](url)` so a + /// host like the Loom board shows it on the task card. Writes a `finding` + /// event carrying the link in `meta.artifacts`. + ArtifactAdd { + task_id: String, + /// Short tag: `doc`, `deploy`, `dashboard`, `design`, `pr`, … + #[arg(long)] + kind: String, + /// The link target (URL or path). + #[arg(long)] + url: String, + /// Human label shown on the card. + #[arg(long)] + label: String, + }, /// Reopen a previously closed task (writes a `reopen` event and /// flips status back to `open`). Use when the same scope comes /// back, e.g. a regression on a shipped fix or a follow-up bug @@ -1454,6 +1470,33 @@ fn real_main() -> Result<()> { writer.flush_durable()?; println!("{}", event.event_id); } + Commands::ArtifactAdd { + task_id, + kind, + url, + label, + } => { + 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")); + std::fs::create_dir_all(events_path.parent().unwrap())?; + + // A `finding` event carries the link in meta.artifacts; index_event + // merges it so it renders under the pack's Artifacts. + let mut event = tj_core::event::Event::new( + &task_id, + tj_core::event::EventType::Finding, + tj_core::event::Author::User, + tj_core::event::Source::Cli, + format!("📎 {kind}: {label} — {url}"), + ); + event.meta = tj_core::artifacts::link_event_meta(&kind, &url, &label); + + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + writer.append(&event)?; + writer.flush_durable()?; + println!("{}", event.event_id); + } Commands::Close { task_id, reason, diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index b73a9da..c4bd1ba 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -5788,3 +5788,46 @@ fn close_harvests_git_commit_and_branch_into_pack() { .success() .stdout(contains("feat/harvest-me")); } + +#[test] +fn artifact_add_renders_clickable_link_in_pack() { + let dir = assert_fs::TempDir::new().unwrap(); + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["create", "Card test"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "artifact-add", + &task_id, + "--kind", + "doc", + "--url", + "https://example.com/spec.md", + "--label", + "Design spec", + ]) + .assert() + .success(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("[Design spec](https://example.com/spec.md) (doc)")); +} diff --git a/crates/tj-core/src/artifacts.rs b/crates/tj-core/src/artifacts.rs index 9e7ee76..e733c30 100644 --- a/crates/tj-core/src/artifacts.rs +++ b/crates/tj-core/src/artifacts.rs @@ -27,6 +27,23 @@ pub struct Artifacts { pub files: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub branch_names: Vec, + /// Clickable, typed links for rendering a task card. Additive to the flat + /// token vectors above (which still power artifact search / relatedness): + /// `links` carries a ready-to-click `{kind,url,label}` so a host like the + /// Loom board doesn't reconstruct URLs. Harvested at close (PR/commit/ + /// branch) or attached by the agent via `artifact_add` (doc/deploy/…). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub links: Vec, +} + +/// A typed, clickable reference. `kind` is a short tag (`pr`, `commit`, +/// `branch`, `doc`, `deploy`, `issue`, …); `url` is the link; `label` is the +/// human text shown on the card. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ArtifactLink { + pub kind: String, + pub url: String, + pub label: String, } impl Artifacts { @@ -36,6 +53,7 @@ impl Artifacts { && self.linked_issues.is_empty() && self.files.is_empty() && self.branch_names.is_empty() + && self.links.is_empty() } /// Merge another `Artifacts` into self, preserving insertion order @@ -54,9 +72,27 @@ impl Artifacts { } } } + // links hold a struct, not a String, so they merge separately — + // deduped by full {kind,url,label} equality. + for l in other.links { + if !self.links.iter().any(|x| x == &l) { + self.links.push(l); + } + } } } +/// Build the `event.meta` payload that attaches one [`ArtifactLink`] to an +/// event. `db::index_event` merges `meta["artifacts"]` into the event's +/// artifacts, so a `Finding` (or any) event carrying this meta surfaces the +/// link in the task's pack. Shared by the CLI `artifact-add` command and the +/// MCP `artifact_add` tool so they store identical shapes. +pub fn link_event_meta(kind: &str, url: &str, label: &str) -> serde_json::Value { + serde_json::json!({ + "artifacts": { "links": [ { "kind": kind, "url": url, "label": label } ] } + }) +} + /// Extract artifacts from a single piece of text (event body, prompt, /// tool output — anything stringly-typed). Idempotent and free of I/O. pub fn extract(text: &str) -> Artifacts { @@ -252,6 +288,29 @@ mod tests { assert!(a.is_empty()); } + #[test] + fn merge_dedupes_links_by_full_identity() { + let link = |k: &str, u: &str, l: &str| ArtifactLink { + kind: k.into(), + url: u.into(), + label: l.into(), + }; + let mut a = Artifacts { + links: vec![link("pr", "u/pull/1", "PR #1")], + ..Default::default() + }; + assert!(!a.is_empty()); + a.merge(Artifacts { + links: vec![ + link("pr", "u/pull/1", "PR #1"), // dup → dropped + link("doc", "u/spec.md", "Spec"), // new → kept + ], + ..Default::default() + }); + assert_eq!(a.links.len(), 2); + assert_eq!(a.links[1].kind, "doc"); + } + #[test] fn captures_short_pr_reference_but_not_bare_hash() { let a = extract("merged PR #51 and PR#52, see pull request #53"); diff --git a/crates/tj-core/src/harvest.rs b/crates/tj-core/src/harvest.rs index 31520a8..da8274a 100644 --- a/crates/tj-core/src/harvest.rs +++ b/crates/tj-core/src/harvest.rs @@ -11,52 +11,119 @@ //! Layer-2 of the "perfect pack at close" design. The pure [`build`] decides //! what to keep so the filtering is unit-testable without a live repo. -use crate::artifacts::Artifacts; +use crate::artifacts::{ArtifactLink, Artifacts}; use std::path::Path; use std::process::Command; -/// Pure assembler: turn the raw `(branch, commit, pr_url)` git/gh outputs into -/// a clean [`Artifacts`], dropping the values that aren't real refs — a -/// detached HEAD (`"HEAD"`), empty strings, or a non-http PR line. Separated -/// from the IO so the keep/drop rules can be tested without spawning git. -pub fn build(branch: Option, commit: Option, pr_url: Option) -> Artifacts { +/// Raw git/gh outputs for one repo, before filtering. All optional and +/// best-effort — [`build`] decides what survives. +#[derive(Debug, Default, Clone)] +pub struct Raw { + pub branch: Option, + pub commit_short: Option, + pub commit_full: Option, + pub pr_url: Option, + pub repo_url: Option, +} + +/// Pure assembler: turn raw git/gh outputs into a clean [`Artifacts`], dropping +/// the values that aren't real refs — a detached HEAD (`"HEAD"`), empty +/// strings, a non-http PR/repo line. Also emits clickable [`ArtifactLink`]s +/// when the repo web URL is known (so a commit hash becomes a real link). +/// Separated from the IO so the keep/drop rules are unit-testable without git. +pub fn build(raw: Raw) -> Artifacts { let mut a = Artifacts::default(); - if let Some(b) = branch { - let b = b.trim(); - // "HEAD" means detached — not a branch name worth recording. - if !b.is_empty() && b != "HEAD" { - a.branch_names.push(b.to_string()); - } - } - if let Some(c) = commit { - let c = c.trim(); - if !c.is_empty() { - a.commit_hashes.push(c.to_string()); - } - } - if let Some(u) = pr_url { - let u = u.trim(); - if u.starts_with("http") { - a.pr_urls.push(u.to_string()); - } + + let branch = raw + .branch + .map(|b| b.trim().to_string()) + .filter(|b| !b.is_empty() && b != "HEAD"); + let commit_short = raw + .commit_short + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()); + let commit_full = raw + .commit_full + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()); + let pr_url = raw + .pr_url + .map(|u| u.trim().to_string()) + .filter(|u| u.starts_with("http")); + let repo = raw + .repo_url + .map(|r| r.trim().trim_end_matches('/').to_string()) + .filter(|r| r.starts_with("http")); + + // Flat token vectors (power artifact search / relatedness, unchanged). + if let Some(b) = &branch { + a.branch_names.push(b.clone()); + } + if let Some(c) = &commit_short { + a.commit_hashes.push(c.clone()); + } + if let Some(u) = &pr_url { + a.pr_urls.push(u.clone()); + } + + // Clickable typed links for the card. + if let Some(u) = &pr_url { + let label = pr_number(u) + .map(|n| format!("PR #{n}")) + .unwrap_or_else(|| "PR".into()); + a.links.push(ArtifactLink { + kind: "pr".into(), + url: u.clone(), + label, + }); + } + if let (Some(repo), Some(full), Some(short)) = (&repo, &commit_full, &commit_short) { + a.links.push(ArtifactLink { + kind: "commit".into(), + url: format!("{repo}/commit/{full}"), + label: short.clone(), + }); + } + if let (Some(repo), Some(b)) = (&repo, &branch) { + a.links.push(ArtifactLink { + kind: "branch".into(), + url: format!("{repo}/tree/{b}"), + label: b.clone(), + }); } a } +/// Trailing PR/MR number from a GitHub/GitLab URL (`…/pull/54` → `54`). +fn pr_number(url: &str) -> Option<&str> { + let tail = url.trim_end_matches('/').rsplit('/').next()?; + if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) { + Some(tail) + } else { + None + } +} + /// Harvest commit/branch/PR refs from the git repo at `dir`. Best-effort; /// returns an empty [`Artifacts`] when `dir` is not a repo or the tools are /// absent. Used at task close to stamp deterministic refs onto the close event. pub fn harvest(dir: &Path) -> Artifacts { - let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]); - let commit = git(dir, &["rev-parse", "--short", "HEAD"]); + let commit_full = git(dir, &["rev-parse", "HEAD"]); // PR resolution, best-effort and in order of reliability: // 1. the open PR for the current branch (pre-merge close), else // 2. the merged PR that contains HEAD (post-merge close, branch gone). - // The second covers the common case where the task is closed on `main` - // after the branch was deleted, so `gh pr view` finds nothing. - let pr_url = gh_pr_url(dir) - .or_else(|| git(dir, &["rev-parse", "HEAD"]).and_then(|sha| gh_pr_for_commit(dir, &sha))); - build(branch, commit, pr_url) + let pr_url = gh_pr_url(dir).or_else(|| { + commit_full + .as_deref() + .and_then(|sha| gh_pr_for_commit(dir, sha)) + }); + build(Raw { + branch: git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]), + commit_short: git(dir, &["rev-parse", "--short", "HEAD"]), + commit_full, + pr_url, + repo_url: gh_repo_url(dir), + }) } /// Run `git -C ` and return trimmed stdout, or `None` on any @@ -123,37 +190,87 @@ fn gh_pr_for_commit(dir: &Path, sha: &str) -> Option { } } +/// Best-effort web URL of the repo (`https://github.com/owner/repo`), used to +/// build clickable commit/branch links. `None` when `gh` is absent or the dir +/// is not a GitHub repo. +fn gh_repo_url(dir: &Path) -> Option { + let out = Command::new("gh") + .args(["repo", "view", "--json", "url", "-q", ".url"]) + .current_dir(dir) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.starts_with("http") { + Some(s) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn build_keeps_real_refs() { - let a = build( - Some("feat/clean-pack".into()), - Some("75f65e2".into()), - Some("https://github.com/o/r/pull/51".into()), - ); + fn build_keeps_real_refs_and_emits_links() { + let a = build(Raw { + branch: Some("feat/clean-pack".into()), + commit_short: Some("75f65e2".into()), + commit_full: Some("75f65e2aaaa".into()), + pr_url: Some("https://github.com/o/r/pull/51".into()), + repo_url: Some("https://github.com/o/r".into()), + }); assert_eq!(a.branch_names, vec!["feat/clean-pack"]); assert_eq!(a.commit_hashes, vec!["75f65e2"]); assert_eq!(a.pr_urls, vec!["https://github.com/o/r/pull/51"]); + // clickable links: PR (labelled by number), commit (full-sha url), branch + let kinds: Vec<_> = a.links.iter().map(|l| l.kind.as_str()).collect(); + assert_eq!(kinds, vec!["pr", "commit", "branch"]); + let pr = &a.links[0]; + assert_eq!(pr.label, "PR #51"); + assert_eq!(a.links[1].url, "https://github.com/o/r/commit/75f65e2aaaa"); + assert_eq!( + a.links[2].url, + "https://github.com/o/r/tree/feat/clean-pack" + ); + } + + #[test] + fn build_without_repo_url_keeps_flat_but_no_commit_branch_links() { + // No repo URL → commit/branch can't be made clickable, but the PR URL + // is self-sufficient so it still yields a link. + let a = build(Raw { + branch: Some("main".into()), + commit_short: Some("abc1234".into()), + commit_full: Some("abc1234ffff".into()), + pr_url: Some("https://github.com/o/r/pull/9".into()), + repo_url: None, + }); + let kinds: Vec<_> = a.links.iter().map(|l| l.kind.as_str()).collect(); + assert_eq!(kinds, vec!["pr"], "only the self-linking PR survives"); + assert_eq!(a.commit_hashes, vec!["abc1234"]); } #[test] fn build_drops_detached_head_empty_and_non_http() { - let a = build( - Some("HEAD".into()), - Some(" ".into()), - Some("no pull request".into()), - ); + let a = build(Raw { + branch: Some("HEAD".into()), + commit_short: Some(" ".into()), + commit_full: None, + pr_url: Some("no pull request".into()), + repo_url: Some("not-a-url".into()), + }); assert!( a.is_empty(), - "detached HEAD + empty commit + non-url PR all dropped" + "detached HEAD + empty commit + non-url PR/repo all dropped" ); } #[test] fn build_tolerates_all_absent() { - assert!(build(None, None, None).is_empty()); + assert!(build(Raw::default()).is_empty()); } } diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 5b67d78..b575070 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -399,6 +399,16 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res if !arts.branch_names.is_empty() { text.push_str(&format!("- branches: {}\n", arts.branch_names.join(", "))); } + if !arts.links.is_empty() { + // Clickable, typed links for the task card: "[label](url) (kind)". + let rendered = arts + .links + .iter() + .map(|l| format!("[{}]({}) ({})", l.label, l.url, l.kind)) + .collect::>() + .join(", "); + text.push_str(&format!("- links: {rendered}\n")); + } } text.push('\n'); diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 902c5a6..1289331 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -235,6 +235,22 @@ pub struct EventAddResult { pub event_type: String, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ArtifactAddParams { + pub task_id: String, + /// Short tag: `doc`, `deploy`, `dashboard`, `design`, `pr`, … + pub kind: String, + /// The link target (URL or path). + pub url: String, + /// Human label shown on the card. + pub label: String, +} +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ArtifactAddResult { + pub event_id: String, + pub task_id: String, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct TaskCloseParams { pub task_id: String, @@ -612,6 +628,47 @@ impl TaskJournalServer { .await } + #[tool( + name = "artifact_add", + description = "Attach a clickable, typed link to a task — a doc, deploy, dashboard, design, spec, etc. Renders on the task card / resume pack under Artifacts as [label](url). Use it when the work produces a reference a human would want to click later. Writes a `finding` event carrying the link; PR/commit/branch are harvested automatically at close, so use this for the things git can't give you." + )] + async fn artifact_add( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("artifact_add", async move { + run_blocking(move || { + let (_, events_path, _) = project_paths()?; + std::fs::create_dir_all(events_path.parent().unwrap())?; + + let mut event = tj_core::event::Event::new( + &p.task_id, + tj_core::event::EventType::Finding, + tj_core::event::Author::Agent, + tj_core::event::Source::Chat, + format!("📎 {}: {} — {}", p.kind, p.label, p.url), + ); + event.meta = tj_core::artifacts::link_event_meta(&p.kind, &p.url, &p.label); + tj_core::session_id::stamp_session_id( + &mut event.meta, + tj_core::session_id::session_id_from_env().as_deref(), + ); + + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + writer.append(&event)?; + writer.flush_durable()?; + + Ok(ArtifactAddResult { + event_id: event.event_id, + task_id: p.task_id.clone(), + }) + }) + .await + .map(Json) + }) + .await + } + #[tool( name = "task_close", description = "Close a task with reason and outcome." From 63424beeb176cea3fdd383e0bd76cf89e1a79897 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Wed, 17 Jun 2026 16:25:10 +0400 Subject: [PATCH 2/2] fix(deps): bump inter-crate tj-core pin to 0.27.0 The path deps pinned task-journal-core = "0.26.1" (^0.26.1 excludes 0.27.0); the first minor bump broke registry resolution in CI. Match the pin to the workspace version. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 6 +++--- crates/tj-cli/Cargo.toml | 2 +- crates/tj-mcp/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b6756b3..7543518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.26.6" +version = "0.27.0" dependencies = [ "anyhow", "assert_cmd", @@ -2540,7 +2540,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.26.6" +version = "0.27.0" dependencies = [ "anyhow", "chrono", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.26.6" +version = "0.27.0" dependencies = [ "anyhow", "chrono", diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index 9bdf901..7ee63fe 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.26.1", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.27.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 74b6df6..99054af 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.26.1", path = "../tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.27.0", path = "../tj-core", default-features = false } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true }