diff --git a/CHANGELOG.md b/CHANGELOG.md index 0272312..c33ab91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.3] - 2026-06-16 + +### Added +- **Loom spine — one journal per board task.** When the MCP runs inside a Loom + task session (`LOOM_TASK_ID` set), `task_create` resolves or creates a single + journal keyed by an `loom:` external reference, so every `task_create` + call in the pipeline shares the same journal and the agent's reasoning lands + on the board task's history. Env-gated: without `LOOM_TASK_ID` behavior is + unchanged. Adds `db::task_id_by_external` for idempotent resolution. +- **`pack --external`** — `task-journal pack --external loom:t-abc` renders a + task's resume pack by its external reference instead of its `tj` id, so a + consumer that only knows the board task id can fetch its journal. The + positional task id still works unchanged. + ## [0.26.2] - 2026-06-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index c695268..c390e33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2572,7 +2572,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.26.2" +version = "0.26.3" dependencies = [ "anyhow", "assert_cmd", @@ -2596,7 +2596,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.26.2" +version = "0.26.3" dependencies = [ "anyhow", "chrono", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.26.2" +version = "0.26.3" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 215a8f3..67cdc7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.26.2" +version = "0.26.3" 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 ffc93d1..57477a8 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -686,8 +686,12 @@ enum Commands { }, /// Render and print the resume pack for a task. Pack { - /// Task id (e.g. tj-7f3a). - task_id: String, + /// Task id (e.g. tj-7f3a). Optional when --external is given. + task_id: Option, + /// Resolve the task by an external reference instead of its id + /// (e.g. `loom:t-abc`). Mutually exclusive with a positional id. + #[arg(long)] + external: Option, /// Output mode: compact|full. #[arg(long, default_value = "compact")] mode: String, @@ -1211,7 +1215,11 @@ fn real_main() -> Result<()> { } } }, - Commands::Pack { task_id, mode } => { + Commands::Pack { + task_id, + external, + mode, + } => { 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")); @@ -1221,12 +1229,21 @@ fn real_main() -> Result<()> { if events_path.exists() { tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; } + // Resolve the target task: explicit id, else by external reference. + let resolved = match (task_id, external) { + (Some(id), _) => id, + (None, Some(ext)) => match tj_core::db::task_id_by_external(&conn, &ext)? { + Some(id) => id, + None => anyhow::bail!("no task with external reference: {ext}"), + }, + (None, None) => anyhow::bail!("a task id or --external is required"), + }; let pmode = match mode.as_str() { "compact" => tj_core::pack::PackMode::Compact, "full" => tj_core::pack::PackMode::Full, other => anyhow::bail!("unknown mode: {other}"), }; - let pack = tj_core::pack::assemble(&conn, &task_id, pmode)?; + let pack = tj_core::pack::assemble(&conn, &resolved, pmode)?; print!("{}", pack.text); } Commands::RebuildState => { diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 389bda6..026081e 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -1,5 +1,5 @@ use anyhow::Context; -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension}; use std::collections::HashSet; use std::path::Path; @@ -455,6 +455,22 @@ pub fn add_task_external(conn: &Connection, task_id: &str, reference: &str) -> a Ok(()) } +/// Find the task whose `external` list contains exactly `reference` (one of the +/// comma-separated tokens). Used to make a journal idempotent by external id — +/// e.g. resolve `loom:t-abc` back to its task. Returns the most recently +/// touched match, or None. +pub fn task_id_by_external(conn: &Connection, reference: &str) -> anyhow::Result> { + let pattern = format!("%,{reference},%"); + let id: Option = conn + .query_row( + "SELECT task_id FROM tasks WHERE ',' || external || ',' LIKE ?1 ORDER BY rowid DESC LIMIT 1", + rusqlite::params![pattern], + |r| r.get::<_, String>(0), + ) + .optional()?; + Ok(id) +} + /// Read-only metadata bundle used by pack rendering (and TUI list /// teasers in v0.4.0+). Returns `None` for unknown tasks. #[derive(Debug, Clone, Default)] @@ -2102,6 +2118,26 @@ mod tests { assert_eq!(parent, None); } + #[test] + fn task_id_by_external_resolves_exact_token() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + upsert_task_from_event(&conn, &make_open_event("tj-a", "A"), "ph").unwrap(); + upsert_task_from_event(&conn, &make_open_event("tj-b", "B"), "ph").unwrap(); + // tj-b carries two external refs incl. the loom one. + add_task_external(&conn, "tj-b", "github:#7").unwrap(); + add_task_external(&conn, "tj-b", "loom:t-xyz").unwrap(); + + assert_eq!( + task_id_by_external(&conn, "loom:t-xyz").unwrap().as_deref(), + Some("tj-b") + ); + // exact token match: a different id does not match + assert_eq!(task_id_by_external(&conn, "loom:t-other").unwrap(), None); + // no false-positive on a substring of a token + assert_eq!(task_id_by_external(&conn, "loom:t-xy").unwrap(), None); + } + #[test] fn open_event_meta_parent_id_is_persisted() { let d = tempfile::TempDir::new().unwrap(); diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 79de372..344c7fe 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -459,6 +459,29 @@ impl TaskJournalServer { let task_id = tj_core::new_task_id(); + // Loom spine: when running inside a Loom task session + // (LOOM_TASK_ID set), the journal is keyed by that id via an + // `loom:` external reference. Resolve it first so repeated + // task_create calls (and the whole pipeline) share ONE journal + // per board task. Without LOOM_TASK_ID this is a no-op. + let loom_ref = std::env::var("LOOM_TASK_ID") + .ok() + .filter(|s| !s.is_empty()) + .map(|t| format!("loom:{t}")); + if let Some(ref r) = loom_ref { + let conn_arc = cached_open(&state_path)?; + let conn = conn_arc + .lock() + .map_err(|e| anyhow::anyhow!("connection mutex poisoned: {e}"))?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + if let Some(existing) = tj_core::db::task_id_by_external(&conn, r)? { + return Ok(TaskCreateResult { + task_id: existing, + title: p.title.clone(), + }); + } + } + // Validate --parent before writing the open event: the parent // must exist and the link must not introduce a cycle. Needs the // derived SQLite state, so ingest the JSONL tail first. @@ -509,6 +532,17 @@ impl TaskJournalServer { tj_core::db::set_task_goal(&conn, &task_id, goal)?; } + // Tag the new journal with its Loom task id so later resolves + // (and the board → journal link) find it. + if let Some(ref r) = loom_ref { + let conn_arc = cached_open(&state_path)?; + let conn = conn_arc + .lock() + .map_err(|e| anyhow::anyhow!("connection mutex poisoned: {e}"))?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + tj_core::db::add_task_external(&conn, &task_id, r)?; + } + Ok(TaskCreateResult { task_id, title: p.title.clone(),