Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.26.2"
version = "0.26.3"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
25 changes: 21 additions & 4 deletions crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// Output mode: compact|full.
#[arg(long, default_value = "compact")]
mode: String,
Expand Down Expand Up @@ -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"));
Expand All @@ -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 => {
Expand Down
38 changes: 37 additions & 1 deletion crates/tj-core/src/db.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anyhow::Context;
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use std::collections::HashSet;
use std::path::Path;

Expand Down Expand Up @@ -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<Option<String>> {
let pattern = format!("%,{reference},%");
let id: Option<String> = 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)]
Expand Down Expand Up @@ -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();
Expand Down
34 changes: 34 additions & 0 deletions crates/tj-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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.
Expand Down Expand Up @@ -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(),
Expand Down
Loading