From 13e0580f93c57f9d7c0eec4bf72962a828dcbd70 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:30:38 +0400 Subject: [PATCH 01/57] feat(completeness): module + NoGoal/ClosedNoOutcome rules --- crates/tj-core/src/completeness.rs | 121 +++++++++++++++++++++++++++++ crates/tj-core/src/lib.rs | 1 + 2 files changed, 122 insertions(+) create mode 100644 crates/tj-core/src/completeness.rs diff --git a/crates/tj-core/src/completeness.rs b/crates/tj-core/src/completeness.rs new file mode 100644 index 0000000..d6126f5 --- /dev/null +++ b/crates/tj-core/src/completeness.rs @@ -0,0 +1,121 @@ +//! Capture completeness: deterministic, read-only detection of structural +//! gaps in a task's captured history. Measure + flag only — no mutation. + +use rusqlite::Connection; + +#[derive(Debug, Clone, PartialEq)] +pub enum GapKind { + ClosedNoOutcome, + DecisionNoEvidence, + SuggestedUnconfirmed, + NoGoal, + PendingLeak, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Gap { + pub kind: GapKind, + pub detail: String, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CompletenessReport { + pub gaps: Vec, +} + +impl CompletenessReport { + pub fn is_complete(&self) -> bool { + self.gaps.is_empty() + } +} + +/// Assess a task's captured history for structural gaps. Deterministic and +/// read-only. `pending_count` (project-level unprocessed entries) is injected +/// so this fn stays filesystem-free and unit-testable. +pub fn assess( + conn: &Connection, + task_id: &str, + pending_count: usize, +) -> anyhow::Result { + let mut gaps = Vec::new(); + + // Metadata rules: read status/goal/outcome from the tasks row. + let row: Option<(String, Option, Option)> = conn + .query_row( + "SELECT status, goal, outcome FROM tasks WHERE task_id = ?1", + rusqlite::params![task_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .ok(); + + let Some((status, goal, outcome)) = row else { + // Unknown task → empty report (no panic). + return Ok(CompletenessReport { gaps }); + }; + + if goal.as_deref().unwrap_or("").is_empty() { + gaps.push(Gap { + kind: GapKind::NoGoal, + detail: "no goal recorded".to_string(), + }); + } + if status == "closed" + && !goal.as_deref().unwrap_or("").is_empty() + && outcome.as_deref().unwrap_or("").is_empty() + { + gaps.push(Gap { + kind: GapKind::ClosedNoOutcome, + detail: "closed without a recorded outcome".to_string(), + }); + } + + // (event rules added in Task 2; pending rule in Task 3) + let _ = pending_count; + + Ok(CompletenessReport { gaps }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::{Author, Event, EventType, Source}; + use tempfile::TempDir; + + fn conn() -> (TempDir, Connection) { + let d = TempDir::new().unwrap(); + let c = crate::db::open(d.path().join("s.sqlite")).unwrap(); + (d, c) + } + + fn open_task(c: &Connection, id: &str) { + let e = Event::new(id, EventType::Open, Author::User, Source::Cli, id.into()); + crate::db::upsert_task_from_event(c, &e, "ph").unwrap(); + } + + #[test] + fn no_goal_fires_when_goal_absent() { + let (_d, c) = conn(); + open_task(&c, "t1"); + let r = assess(&c, "t1", 0).unwrap(); + assert!(r.gaps.iter().any(|g| g.kind == GapKind::NoGoal)); + } + + #[test] + fn closed_no_outcome_fires() { + let (_d, c) = conn(); + open_task(&c, "t2"); + // Set a goal, then close without outcome. + c.execute("UPDATE tasks SET goal='ship X' WHERE task_id='t2'", []).unwrap(); + c.execute("UPDATE tasks SET status='closed' WHERE task_id='t2'", []).unwrap(); + let r = assess(&c, "t2", 0).unwrap(); + assert!(r.gaps.iter().any(|g| g.kind == GapKind::ClosedNoOutcome)); + assert!(!r.gaps.iter().any(|g| g.kind == GapKind::NoGoal)); + } + + #[test] + fn unknown_task_is_empty_report() { + let (_d, c) = conn(); + let r = assess(&c, "nope", 0).unwrap(); + assert!(r.is_complete()); + } +} diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 42d7a27..040145c 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -49,6 +49,7 @@ mod task_id_tests { pub mod artifacts; pub mod classifier; +pub mod completeness; pub mod db; pub mod event; pub mod fts; From 7ba6f9f067b3fb245e52337512b673cd7fc91ece Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:31:31 +0400 Subject: [PATCH 02/57] feat(completeness): decision-no-evidence + suggested-unconfirmed rules --- crates/tj-core/src/completeness.rs | 73 +++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/tj-core/src/completeness.rs b/crates/tj-core/src/completeness.rs index d6126f5..8e064b2 100644 --- a/crates/tj-core/src/completeness.rs +++ b/crates/tj-core/src/completeness.rs @@ -69,7 +69,43 @@ pub fn assess( }); } - // (event rules added in Task 2; pending rule in Task 3) + // Event rules: tally types and statuses for this task. + let mut decisions = 0usize; + let mut evidence = 0usize; + let mut suggested = 0usize; + { + let mut stmt = conn.prepare( + "SELECT type, status FROM events_index WHERE task_id = ?1", + )?; + let rows = stmt.query_map(rusqlite::params![task_id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) + })?; + for row in rows { + let (ty, st) = row?; + match ty.as_str() { + "decision" => decisions += 1, + "evidence" => evidence += 1, + _ => {} + } + if st == "suggested" { + suggested += 1; + } + } + } + if decisions > 0 && evidence == 0 { + gaps.push(Gap { + kind: GapKind::DecisionNoEvidence, + detail: "decisions unverified (no evidence captured)".to_string(), + }); + } + if suggested > 0 { + gaps.push(Gap { + kind: GapKind::SuggestedUnconfirmed, + detail: format!("{suggested} suggested event(s) unconfirmed"), + }); + } + + // (pending rule in Task 3) let _ = pending_count; Ok(CompletenessReport { gaps }) @@ -92,6 +128,13 @@ mod tests { crate::db::upsert_task_from_event(c, &e, "ph").unwrap(); } + fn add_event(c: &Connection, task: &str, ty: EventType, status: crate::event::EventStatus) { + let mut e = Event::new(task, ty, Author::Agent, Source::Hook, "x".into()); + e.status = status; + crate::db::upsert_task_from_event(c, &e, "ph").unwrap(); + crate::db::index_event(c, &e).unwrap(); + } + #[test] fn no_goal_fires_when_goal_absent() { let (_d, c) = conn(); @@ -118,4 +161,32 @@ mod tests { let r = assess(&c, "nope", 0).unwrap(); assert!(r.is_complete()); } + + #[test] + fn decision_without_evidence_fires_then_clears() { + use crate::event::EventStatus; + let (_d, c) = conn(); + open_task(&c, "t3"); + c.execute("UPDATE tasks SET goal='g' WHERE task_id='t3'", []).unwrap(); + add_event(&c, "t3", EventType::Decision, EventStatus::Confirmed); + let r = assess(&c, "t3", 0).unwrap(); + assert!(r.gaps.iter().any(|g| g.kind == GapKind::DecisionNoEvidence)); + + add_event(&c, "t3", EventType::Evidence, EventStatus::Confirmed); + let r2 = assess(&c, "t3", 0).unwrap(); + assert!(!r2.gaps.iter().any(|g| g.kind == GapKind::DecisionNoEvidence)); + } + + #[test] + fn suggested_unconfirmed_counts() { + use crate::event::EventStatus; + let (_d, c) = conn(); + open_task(&c, "t4"); + c.execute("UPDATE tasks SET goal='g' WHERE task_id='t4'", []).unwrap(); + add_event(&c, "t4", EventType::Finding, EventStatus::Suggested); + add_event(&c, "t4", EventType::Finding, EventStatus::Suggested); + let r = assess(&c, "t4", 0).unwrap(); + let g = r.gaps.iter().find(|g| g.kind == GapKind::SuggestedUnconfirmed).unwrap(); + assert!(g.detail.contains('2')); + } } From 144eab5b2102585ac7b92837059c6b3e3c3bb7b1 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:31:52 +0400 Subject: [PATCH 03/57] feat(completeness): pending-leak rule --- crates/tj-core/src/completeness.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/tj-core/src/completeness.rs b/crates/tj-core/src/completeness.rs index 8e064b2..6098273 100644 --- a/crates/tj-core/src/completeness.rs +++ b/crates/tj-core/src/completeness.rs @@ -105,8 +105,13 @@ pub fn assess( }); } - // (pending rule in Task 3) - let _ = pending_count; + if pending_count > 0 { + gaps.push(Gap { + kind: GapKind::PendingLeak, + detail: format!("{pending_count} pending entr{} not yet classified", + if pending_count == 1 { "y" } else { "ies" }), + }); + } Ok(CompletenessReport { gaps }) } @@ -189,4 +194,17 @@ mod tests { let g = r.gaps.iter().find(|g| g.kind == GapKind::SuggestedUnconfirmed).unwrap(); assert!(g.detail.contains('2')); } + + #[test] + fn pending_leak_fires_when_count_positive() { + let (_d, c) = conn(); + open_task(&c, "t5"); + c.execute("UPDATE tasks SET goal='g' WHERE task_id='t5'", []).unwrap(); + let r = assess(&c, "t5", 3).unwrap(); + let g = r.gaps.iter().find(|g| g.kind == GapKind::PendingLeak).unwrap(); + assert!(g.detail.contains('3')); + + let r0 = assess(&c, "t5", 0).unwrap(); + assert!(!r0.gaps.iter().any(|g| g.kind == GapKind::PendingLeak)); + } } From 20334cb576d808f4c80b30664966599eb5e35b0d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:32:20 +0400 Subject: [PATCH 04/57] feat(completeness): best-effort pending_count fs helper --- crates/tj-core/src/completeness.rs | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/tj-core/src/completeness.rs b/crates/tj-core/src/completeness.rs index 6098273..765ab43 100644 --- a/crates/tj-core/src/completeness.rs +++ b/crates/tj-core/src/completeness.rs @@ -116,6 +116,36 @@ pub fn assess( Ok(CompletenessReport { gaps }) } +/// Best-effort count of unprocessed pending entries for the cwd's project. +/// Returns 0 on any resolution/IO error — the PendingLeak rule then stays +/// silent rather than failing the whole assessment. +pub fn pending_count() -> usize { + fn inner() -> anyhow::Result { + let cwd = std::env::current_dir()?; + let project_hash = crate::project_hash::from_path(&cwd)?; + let events_path = + crate::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let dir = events_path + .parent() + .and_then(|p| p.parent()) + .ok_or_else(|| anyhow::anyhow!("no grandparent"))? + .join("pending"); + if !dir.exists() { + return Ok(0); + } + let mut n = 0; + for entry in std::fs::read_dir(&dir)? { + let path = entry?.path(); + // Count live .json chunks; skip .dead and non-json. + if path.extension().and_then(|e| e.to_str()) == Some("json") { + n += 1; + } + } + Ok(n) + } + inner().unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -207,4 +237,11 @@ mod tests { let r0 = assess(&c, "t5", 0).unwrap(); assert!(!r0.gaps.iter().any(|g| g.kind == GapKind::PendingLeak)); } + + #[test] + fn pending_count_zero_when_no_dir() { + // Best-effort contract: resolution may succeed or fail, but it must + // never panic. In a clean env with no pending dir the count is 0. + let _ = pending_count(); + } } From 418705a1269d551fce20ac2e22f73b9e1dc4fe2f Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:34:26 +0400 Subject: [PATCH 05/57] feat(completeness): render section + pack integration --- crates/tj-core/src/completeness.rs | 28 ++++++++++++++++++++ crates/tj-core/src/pack.rs | 41 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/crates/tj-core/src/completeness.rs b/crates/tj-core/src/completeness.rs index 765ab43..a235af9 100644 --- a/crates/tj-core/src/completeness.rs +++ b/crates/tj-core/src/completeness.rs @@ -146,6 +146,18 @@ pub fn pending_count() -> usize { inner().unwrap_or(0) } +/// Render the Completeness section, or None when there are no gaps. +pub fn render_section(report: &CompletenessReport) -> Option { + if report.gaps.is_empty() { + return None; + } + let mut s = format!("\n## Completeness ({})\n", report.gaps.len()); + for g in &report.gaps { + s.push_str(&format!("- ⚠ {}\n", g.detail)); + } + Some(s) +} + #[cfg(test)] mod tests { use super::*; @@ -244,4 +256,20 @@ mod tests { // never panic. In a clean env with no pending dir the count is 0. let _ = pending_count(); } + + #[test] + fn render_section_none_when_complete() { + let r = CompletenessReport::default(); + assert!(render_section(&r).is_none()); + } + + #[test] + fn render_section_lists_gaps() { + let r = CompletenessReport { + gaps: vec![Gap { kind: GapKind::NoGoal, detail: "no goal recorded".into() }], + }; + let s = render_section(&r).unwrap(); + assert!(s.contains("Completeness (1)")); + assert!(s.contains("no goal recorded")); + } } diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index a2230d3..a80bf6f 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -337,6 +337,11 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res }; text.push_str(&render_recent_events(conn, task_id, recent_limit)?); + let report = crate::completeness::assess(conn, task_id, crate::completeness::pending_count())?; + if let Some(section) = crate::completeness::render_section(&report) { + text.push_str(§ion); + } + // Token-budget truncation: cap pack size so it always fits an LLM context window. // v0.10.3: full bumped 10K → 24K. Real tasks accumulate 50-100 events // and the prior cap clipped final-summary decisions even after the @@ -387,6 +392,42 @@ mod tests { assert_eq!(s, "\"Compact\""); } + #[test] + fn pack_shows_completeness_section_when_gaps() { + let d = tempfile::TempDir::new().unwrap(); + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + // Task with no goal → NoGoal gap. + let e = crate::event::Event::new("g1", crate::event::EventType::Open, + crate::event::Author::User, crate::event::Source::Cli, "T".into()); + crate::db::upsert_task_from_event(&conn, &e, "ph").unwrap(); + crate::db::index_event(&conn, &e).unwrap(); + + let pack = assemble(&conn, "g1", PackMode::Compact).unwrap(); + assert!(pack.text.contains("Completeness")); + assert!(pack.text.contains("no goal recorded")); + } + + #[test] + fn pack_no_completeness_section_when_complete() { + let d = tempfile::TempDir::new().unwrap(); + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + let mut e = crate::event::Event::new("g2", crate::event::EventType::Open, + crate::event::Author::User, crate::event::Source::Cli, "T".into()); + e.meta = serde_json::json!({"title": "T"}); + crate::db::upsert_task_from_event(&conn, &e, "ph").unwrap(); + crate::db::index_event(&conn, &e).unwrap(); + // Give it a goal so NoGoal doesn't fire; open + no decisions → complete. + conn.execute("UPDATE tasks SET goal='g' WHERE task_id='g2'", []).unwrap(); + // pending_count() resolves `/pending`. Point the data dir at + // the isolated tempdir (no pending/ child) so the PendingLeak rule + // stays silent regardless of the real dev environment. + std::env::set_var("TASK_JOURNAL_DATA_DIR", d.path()); + + let pack = assemble(&conn, "g2", PackMode::Compact).unwrap(); + std::env::remove_var("TASK_JOURNAL_DATA_DIR"); + assert!(!pack.text.contains("## Completeness")); + } + #[test] fn cache_is_invalidated_on_new_event() { use crate::db; From f0162a3f201d8f9bfffd86cb713575375c449b97 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:35:37 +0400 Subject: [PATCH 06/57] chore(completeness): version bump + CHANGELOG --- CHANGELOG.md | 9 +++++++++ Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/tj-cli/Cargo.toml | 2 +- crates/tj-mcp/Cargo.toml | 2 +- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec32620..3045009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] + +### Added +- Capture-completeness flagging: a task's resume-pack now shows a `Completeness` + section listing structural gaps (closed without outcome, decisions without + evidence, unconfirmed suggested events, missing goal, unclassified pending + entries) — shown only when gaps exist. Read-only; reusable + `completeness::assess` API for the upcoming close-gate. + ## [0.11.1] - 2026-06-08 **Fix: `pack` panicked on multibyte UTF-8.** Pack truncation sliced the diff --git a/Cargo.lock b/Cargo.lock index f3bf04c..ab0f00e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "assert_cmd", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "chrono", @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index d52906a..4d922ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.11.1" +version = "0.12.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index bde4f94..60dec1e 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.11.1", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.12.0", path = "../tj-core" } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 803b35e..adfe5fb 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal-mcp" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.11.1", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.12.0", path = "../tj-core" } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } From e85bb1cb213ccd6e3c602be93c921b93af8cac3c Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:39:06 +0400 Subject: [PATCH 07/57] feat(dream): add Source::Dream event provenance Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/event.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tj-core/src/event.rs b/crates/tj-core/src/event.rs index d4a88be..199749b 100644 --- a/crates/tj-core/src/event.rs +++ b/crates/tj-core/src/event.rs @@ -51,6 +51,7 @@ pub enum Source { Hook, Manual, Cli, + Dream, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -169,6 +170,14 @@ mod tests { ); } + #[test] + fn source_dream_serializes_to_snake_case() { + let j = serde_json::to_string(&Source::Dream).unwrap(); + assert_eq!(j, "\"dream\""); + let back: Source = serde_json::from_str("\"dream\"").unwrap(); + assert_eq!(back, Source::Dream); + } + #[test] fn event_new_assigns_ulid_and_now() { let a = Event::new( From 3fe8bd67d02359cf4c38b9806fb90d93b2381923 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:39:48 +0400 Subject: [PATCH 08/57] feat(dream): backend trait, backfill IO types, mock backend Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/backend.rs | 75 +++++++++++++++++++++++++++++ crates/tj-core/src/dream/mod.rs | 7 +++ crates/tj-core/src/lib.rs | 1 + 3 files changed, 83 insertions(+) create mode 100644 crates/tj-core/src/dream/backend.rs create mode 100644 crates/tj-core/src/dream/mod.rs diff --git a/crates/tj-core/src/dream/backend.rs b/crates/tj-core/src/dream/backend.rs new file mode 100644 index 0000000..69d4ad5 --- /dev/null +++ b/crates/tj-core/src/dream/backend.rs @@ -0,0 +1,75 @@ +//! Backend abstraction for dream Pass A: given a task's existing events +//! and a full transcript, return the significant events that were missed. + +use crate::event::EventType; +use serde::{Deserialize, Serialize}; + +/// One missed event the backend proposes appending. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct BackfillEvent { + pub event_type: EventType, + /// Which task this belongs to (one of the input's candidate task ids). + pub task_id: String, + pub text: String, + /// RFC3339 timestamp of the transcript turn this was inferred from, + /// so the event sorts into its correct place in the chain. + pub timestamp: String, +} + +/// Input for one session's backfill call. +#[derive(Debug, Clone, Serialize)] +pub struct BackfillInput { + /// Candidate task contexts active in this session. + pub tasks: Vec, + /// The full session transcript, flattened to role-tagged turns. + pub transcript: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BackfillTaskContext { + pub task_id: String, + pub title: String, + /// Text of events already captured for this task (for dedup context). + pub existing_events: Vec, +} + +pub trait DreamBackend { + /// Return the events the realtime classifier missed for this session. + fn backfill(&self, input: &BackfillInput) -> anyhow::Result>; +} + +/// Test backend that returns a canned list, ignoring the input. +pub struct MockDreamBackend { + pub events: Vec, +} + +impl DreamBackend for MockDreamBackend { + fn backfill(&self, _input: &BackfillInput) -> anyhow::Result> { + Ok(self.events.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_backend_returns_canned_events() { + let be = MockDreamBackend { + events: vec![BackfillEvent { + event_type: EventType::Decision, + task_id: "tj-1".into(), + text: "Chose A over B.".into(), + timestamp: "2026-06-08T10:00:00Z".into(), + }], + }; + let input = BackfillInput { + tasks: vec![], + transcript: "x".into(), + }; + let out = be.backfill(&input).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].task_id, "tj-1"); + assert_eq!(out[0].event_type, EventType::Decision); + } +} diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs new file mode 100644 index 0000000..272c231 --- /dev/null +++ b/crates/tj-core/src/dream/mod.rs @@ -0,0 +1,7 @@ +//! Dream — offline memory passes over session transcripts. +//! +//! Pass A (backfill): re-read a session transcript and append the +//! significant typed events the realtime classifier missed. Additive — +//! the JSONL source of truth is never mutated. + +pub mod backend; diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 040145c..77a1c2f 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -51,6 +51,7 @@ pub mod artifacts; pub mod classifier; pub mod completeness; pub mod db; +pub mod dream; pub mod event; pub mod fts; pub mod pack; From 2fd339d9c7a7207327bc2c68300ec6983fdc89b9 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:40:19 +0400 Subject: [PATCH 09/57] feat(dream): Pass A prompt builder Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/mod.rs | 1 + crates/tj-core/src/dream/prompt.rs | 81 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 crates/tj-core/src/dream/prompt.rs diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index 272c231..6598909 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -5,3 +5,4 @@ //! the JSONL source of truth is never mutated. pub mod backend; +pub mod prompt; diff --git a/crates/tj-core/src/dream/prompt.rs b/crates/tj-core/src/dream/prompt.rs new file mode 100644 index 0000000..210cfc4 --- /dev/null +++ b/crates/tj-core/src/dream/prompt.rs @@ -0,0 +1,81 @@ +//! Dream Pass A prompt: instruct the model to emit ONLY significant +//! reasoning not already represented in the task's existing events. + +use crate::dream::backend::BackfillInput; + +/// High-signal event types the backfill is allowed to emit. Chatter +/// (greetings, restating output) is explicitly excluded. +pub const ALLOWED_TYPES: &str = "decision, rejection, finding, constraint, hypothesis"; + +pub fn build_prompt(input: &BackfillInput) -> String { + let mut tasks_block = String::new(); + for t in &input.tasks { + tasks_block.push_str(&format!("## Task {} — {}\n", t.task_id, t.title)); + if t.existing_events.is_empty() { + tasks_block.push_str("(no events captured yet)\n"); + } else { + for e in &t.existing_events { + tasks_block.push_str(&format!("- {e}\n")); + } + } + tasks_block.push('\n'); + } + + format!( + "You are a memory-backfill pass over a coding session transcript.\n\ + The realtime classifier already captured some events; your job is to \ + find SIGNIFICANT reasoning it MISSED.\n\n\ + Rules:\n\ + - Emit ONLY events whose substance is NOT already in the existing events below.\n\ + - Allowed event_type values: {types}.\n\ + - Skip chatter, restated tool output, and low-signal turns.\n\ + - Each event MUST set task_id to one of the candidate task ids.\n\ + - timestamp MUST be the RFC3339 timestamp of the transcript turn it came from.\n\ + - Respond with ONLY a JSON array of objects: \ + {{\"event_type\",\"task_id\",\"text\",\"timestamp\"}}. Empty array if nothing missed.\n\n\ + # Candidate tasks and their existing events\n{tasks}\n\ + # Transcript\n{transcript}\n", + types = ALLOWED_TYPES, + tasks = tasks_block, + transcript = input.transcript, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dream::backend::{BackfillInput, BackfillTaskContext}; + + #[test] + fn prompt_includes_tasks_transcript_and_rules() { + let input = BackfillInput { + tasks: vec![BackfillTaskContext { + task_id: "tj-7".into(), + title: "Add dream".into(), + existing_events: vec!["Decided to do two passes.".into()], + }], + transcript: "user: why two passes?\nassistant: because...".into(), + }; + let p = build_prompt(&input); + assert!(p.contains("tj-7")); + assert!(p.contains("Add dream")); + assert!(p.contains("Decided to do two passes.")); + assert!(p.contains("why two passes?")); + assert!(p.contains("decision, rejection, finding, constraint, hypothesis")); + assert!(p.contains("JSON array")); + } + + #[test] + fn prompt_marks_task_with_no_events() { + let input = BackfillInput { + tasks: vec![BackfillTaskContext { + task_id: "tj-9".into(), + title: "Fresh".into(), + existing_events: vec![], + }], + transcript: "x".into(), + }; + let p = build_prompt(&input); + assert!(p.contains("no events captured yet")); + } +} From 0fb636e22e3d137f2eaa9a30c7303d3ddc28357e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:41:01 +0400 Subject: [PATCH 10/57] feat(dream): Anthropic HTTP backend for Pass A Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/http.rs | 143 +++++++++++++++++++++++++++++++ crates/tj-core/src/dream/mod.rs | 1 + 2 files changed, 144 insertions(+) create mode 100644 crates/tj-core/src/dream/http.rs diff --git a/crates/tj-core/src/dream/http.rs b/crates/tj-core/src/dream/http.rs new file mode 100644 index 0000000..a1af1c2 --- /dev/null +++ b/crates/tj-core/src/dream/http.rs @@ -0,0 +1,143 @@ +//! Anthropic API HTTP client implementing DreamBackend. Mirrors +//! classifier::http but returns a list of missed events. + +use crate::dream::backend::{BackfillEvent, BackfillInput, DreamBackend}; +use crate::dream::prompt::build_prompt; +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +/// Stronger than the classifier default — dream sees the whole transcript. +pub const DEFAULT_MODEL: &str = "claude-sonnet-4-6"; +pub const DEFAULT_MAX_TOKENS: u32 = 2048; + +pub struct AnthropicDreamBackend { + pub api_key: String, + pub model: String, + pub base_url: String, + pub max_tokens: u32, + pub timeout: Duration, +} + +impl AnthropicDreamBackend { + pub fn from_env() -> anyhow::Result { + let api_key = + std::env::var("ANTHROPIC_API_KEY").context("ANTHROPIC_API_KEY env var not set")?; + let model = std::env::var("TJ_DREAM_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()); + let max_tokens = std::env::var("TJ_DREAM_MAX_TOKENS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(DEFAULT_MAX_TOKENS); + Ok(Self { + api_key, + model, + base_url: "https://api.anthropic.com".into(), + max_tokens, + timeout: DEFAULT_TIMEOUT, + }) + } +} + +#[derive(Serialize)] +struct MessagesRequest<'a> { + model: &'a str, + max_tokens: u32, + messages: Vec>, +} +#[derive(Serialize)] +struct MessageIn<'a> { + role: &'a str, + content: &'a str, +} +#[derive(Deserialize)] +struct MessagesResponse { + content: Vec, +} +#[derive(Deserialize)] +struct ContentBlock { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + text: String, +} + +impl DreamBackend for AnthropicDreamBackend { + fn backfill(&self, input: &BackfillInput) -> anyhow::Result> { + let prompt = build_prompt(input); + let body = MessagesRequest { + model: &self.model, + max_tokens: self.max_tokens, + messages: vec![MessageIn { + role: "user", + content: &prompt, + }], + }; + let url = format!("{}/v1/messages", self.base_url); + let resp: MessagesResponse = ureq::post(&url) + .timeout(self.timeout) + .set("x-api-key", &self.api_key) + .set("anthropic-version", "2023-06-01") + .set("content-type", "application/json") + .send_json(serde_json::to_value(&body)?) + .context("Anthropic API request failed")? + .into_json() + .context("decode Anthropic response")?; + + let text = resp + .content + .iter() + .find(|b| b.kind == "text") + .map(|b| b.text.clone()) + .ok_or_else(|| anyhow!("no text content in response"))?; + + let json_str = text + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + let out: Vec = serde_json::from_str(json_str) + .with_context(|| format!("dream JSON parse failed; got: {json_str}"))?; + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dream::backend::BackfillInput; + + #[test] + fn backend_parses_event_array() { + let mut server = mockito::Server::new(); + let url = server.url(); + let body = serde_json::json!({ + "content": [ + { "type": "text", "text": "[{\"event_type\":\"finding\",\"task_id\":\"tj-2\",\"text\":\"Found the bug.\",\"timestamp\":\"2026-06-08T10:00:00Z\"}]" } + ] + }); + let _m = server + .mock("POST", "/v1/messages") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create(); + + let be = AnthropicDreamBackend { + api_key: "k".into(), + model: "m".into(), + base_url: url, + max_tokens: 256, + timeout: Duration::from_secs(5), + }; + let input = BackfillInput { + tasks: vec![], + transcript: "t".into(), + }; + let out = be.backfill(&input).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].text, "Found the bug."); + assert_eq!(out[0].task_id, "tj-2"); + } +} diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index 6598909..9a44f1f 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -5,4 +5,5 @@ //! the JSONL source of truth is never mutated. pub mod backend; +pub mod http; pub mod prompt; From ea40310a1212b5c0216f4a468f4a14dbfa2945e1 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:41:45 +0400 Subject: [PATCH 11/57] feat(dream): dream_state watermark migration + accessors Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/db.rs | 14 +++++++++ crates/tj-core/src/dream/mod.rs | 1 + crates/tj-core/src/dream/state.rs | 52 +++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 crates/tj-core/src/dream/state.rs diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 1b9f7ae..2271f5d 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -100,6 +100,16 @@ const MIGRATION_004: &str = r#" DELETE FROM task_pack_cache; "#; +/// v0.12.0 dream Pass A — per-project watermark of the last successful +/// dream run. Sessions modified after this are in scope for the next run. +const MIGRATION_005: &str = r#" +CREATE TABLE IF NOT EXISTS dream_state ( + project_hash TEXT PRIMARY KEY, + last_dream_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +"#; + /// All schema migrations in version order. Append new entries here; never /// edit a published migration's `sql` — write a new one instead. const MIGRATIONS: &[Migration] = &[ @@ -119,6 +129,10 @@ const MIGRATIONS: &[Migration] = &[ version: 4, sql: MIGRATION_004, }, + Migration { + version: 5, + sql: MIGRATION_005, + }, ]; fn apply_migrations(conn: &Connection) -> anyhow::Result<()> { diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index 9a44f1f..e8c6505 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -7,3 +7,4 @@ pub mod backend; pub mod http; pub mod prompt; +pub mod state; diff --git a/crates/tj-core/src/dream/state.rs b/crates/tj-core/src/dream/state.rs new file mode 100644 index 0000000..b778cb6 --- /dev/null +++ b/crates/tj-core/src/dream/state.rs @@ -0,0 +1,52 @@ +//! Per-project dream watermark: the timestamp of the last successful +//! dream run. Sessions modified after this are in scope for the next run. + +use rusqlite::Connection; + +/// Read the last dream run timestamp (RFC3339), if any. +pub fn last_dream_at(conn: &Connection, project_hash: &str) -> anyhow::Result> { + let mut stmt = conn.prepare("SELECT last_dream_at FROM dream_state WHERE project_hash = ?1")?; + let mut rows = stmt.query(rusqlite::params![project_hash])?; + Ok(match rows.next()? { + Some(r) => Some(r.get::<_, String>(0)?), + None => None, + }) +} + +/// Upsert the watermark to `at` (RFC3339). +pub fn set_last_dream_at(conn: &Connection, project_hash: &str, at: &str) -> anyhow::Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO dream_state(project_hash, last_dream_at, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(project_hash) DO UPDATE SET last_dream_at = ?2, updated_at = ?3", + rusqlite::params![project_hash, at, now], + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn watermark_round_trips_and_upserts() { + let d = TempDir::new().unwrap(); + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + + assert_eq!(last_dream_at(&conn, "ph").unwrap(), None); + + set_last_dream_at(&conn, "ph", "2026-06-08T10:00:00+00:00").unwrap(); + assert_eq!( + last_dream_at(&conn, "ph").unwrap().as_deref(), + Some("2026-06-08T10:00:00+00:00") + ); + + set_last_dream_at(&conn, "ph", "2026-06-09T10:00:00+00:00").unwrap(); + assert_eq!( + last_dream_at(&conn, "ph").unwrap().as_deref(), + Some("2026-06-09T10:00:00+00:00") + ); + } +} From 1f8843ee258cfee216d0ec16dc3db0d30a5b5864 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:42:13 +0400 Subject: [PATCH 12/57] feat(dream): session scope resolution (since + limit) Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/mod.rs | 1 + crates/tj-core/src/dream/scope.rs | 95 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 crates/tj-core/src/dream/scope.rs diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index e8c6505..9587c5e 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -7,4 +7,5 @@ pub mod backend; pub mod http; pub mod prompt; +pub mod scope; pub mod state; diff --git a/crates/tj-core/src/dream/scope.rs b/crates/tj-core/src/dream/scope.rs new file mode 100644 index 0000000..583a03e --- /dev/null +++ b/crates/tj-core/src/dream/scope.rs @@ -0,0 +1,95 @@ +//! Resolve which session transcripts are in scope for a dream run. + +use std::time::SystemTime; + +/// A discovered session with its file modification time. +pub struct SessionFile { + pub path: std::path::PathBuf, + pub mtime: SystemTime, +} + +/// Keep sessions modified strictly after `since` (the watermark as a +/// SystemTime). When `since` is None, all sessions are in scope. +/// `limit` (when Some) caps the result to the newest N. +pub fn in_scope( + mut sessions: Vec, + since: Option, + limit: Option, +) -> Vec { + sessions.sort_by_key(|s| std::cmp::Reverse(s.mtime)); + let mut out: Vec = sessions + .into_iter() + .filter(|s| match since { + Some(t) => s.mtime > t, + None => true, + }) + .map(|s| s.path) + .collect(); + if let Some(n) = limit { + out.truncate(n); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn at(secs: u64) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(secs) + } + + #[test] + fn filters_by_since_and_caps_by_limit() { + let s = vec![ + SessionFile { + path: "a".into(), + mtime: at(100), + }, + SessionFile { + path: "b".into(), + mtime: at(200), + }, + SessionFile { + path: "c".into(), + mtime: at(300), + }, + ]; + // since = 150 → keeps b(200) and c(300), newest first + let r = in_scope(s, Some(at(150)), None); + assert_eq!( + r, + vec![ + std::path::PathBuf::from("c"), + std::path::PathBuf::from("b") + ] + ); + } + + #[test] + fn none_since_keeps_all_limit_caps() { + let s = vec![ + SessionFile { + path: "a".into(), + mtime: at(100), + }, + SessionFile { + path: "b".into(), + mtime: at(200), + }, + SessionFile { + path: "c".into(), + mtime: at(300), + }, + ]; + let r = in_scope(s, None, Some(2)); + assert_eq!( + r, + vec![ + std::path::PathBuf::from("c"), + std::path::PathBuf::from("b") + ] + ); + } +} From 3428ad813cd43f9120c2faba90936a22a4dc630c Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:42:48 +0400 Subject: [PATCH 13/57] feat(dream): dedup-guard via token Jaccard similarity Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/backfill.rs | 81 ++++++++++++++++++++++++++++ crates/tj-core/src/dream/mod.rs | 1 + 2 files changed, 82 insertions(+) create mode 100644 crates/tj-core/src/dream/backfill.rs diff --git a/crates/tj-core/src/dream/backfill.rs b/crates/tj-core/src/dream/backfill.rs new file mode 100644 index 0000000..330cd17 --- /dev/null +++ b/crates/tj-core/src/dream/backfill.rs @@ -0,0 +1,81 @@ +//! Pass A per-session backfill: dedup-guard + provenance stamping. + +use crate::dream::backend::BackfillEvent; +use std::collections::HashSet; + +/// Similarity at or above which a proposed event is considered a +/// duplicate of an existing one. Tuned conservative (high) so the guard +/// only drops near-identical restatements, not genuinely new events. +pub const DUP_THRESHOLD: f64 = 0.8; + +fn tokens(s: &str) -> HashSet { + s.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect() +} + +/// Jaccard similarity of two strings' token sets (0.0..=1.0). +pub fn similarity(a: &str, b: &str) -> f64 { + let (ta, tb) = (tokens(a), tokens(b)); + if ta.is_empty() && tb.is_empty() { + return 1.0; + } + let inter = ta.intersection(&tb).count() as f64; + let union = ta.union(&tb).count() as f64; + if union == 0.0 { + 0.0 + } else { + inter / union + } +} + +/// Drop proposed events that are near-duplicates of `existing` texts. +pub fn dedup_guard(proposed: Vec, existing: &[String]) -> Vec { + proposed + .into_iter() + .filter(|p| { + !existing + .iter() + .any(|e| similarity(&p.text, e) >= DUP_THRESHOLD) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::EventType; + + fn ev(text: &str) -> BackfillEvent { + BackfillEvent { + event_type: EventType::Finding, + task_id: "tj-1".into(), + text: text.into(), + timestamp: "2026-06-08T10:00:00Z".into(), + } + } + + #[test] + fn drops_near_duplicate_keeps_novel() { + let existing = vec!["We decided to use SQLite instead of Postgres".to_string()]; + let proposed = vec![ + ev("Decided to use SQLite instead of Postgres"), // near-dup + ev("The cache layer needs a TTL of 60 seconds"), // novel + ]; + let kept = dedup_guard(proposed, &existing); + assert_eq!(kept.len(), 1); + assert!(kept[0].text.contains("TTL")); + } + + #[test] + fn identical_text_is_similarity_one() { + assert!((similarity("hello world", "hello world") - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn disjoint_text_is_similarity_zero() { + assert_eq!(similarity("alpha beta", "gamma delta"), 0.0); + } +} diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index 9587c5e..dd54dcc 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -5,6 +5,7 @@ //! the JSONL source of truth is never mutated. pub mod backend; +pub mod backfill; pub mod http; pub mod prompt; pub mod scope; From 037ac6243270191142ea211c95e8005d73b59324 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:43:28 +0400 Subject: [PATCH 14/57] feat(dream): stamp dream provenance on backfilled events Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/backfill.rs | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/tj-core/src/dream/backfill.rs b/crates/tj-core/src/dream/backfill.rs index 330cd17..952db1a 100644 --- a/crates/tj-core/src/dream/backfill.rs +++ b/crates/tj-core/src/dream/backfill.rs @@ -1,6 +1,7 @@ //! Pass A per-session backfill: dedup-guard + provenance stamping. use crate::dream::backend::BackfillEvent; +use crate::event::{Author, Event, EventStatus, Source}; use std::collections::HashSet; /// Similarity at or above which a proposed event is considered a @@ -43,6 +44,26 @@ pub fn dedup_guard(proposed: Vec, existing: &[String]) -> Vec Event { + let mut e = Event::new( + b.task_id.clone(), + b.event_type, + Author::Agent, + Source::Dream, + b.text.clone(), + ); + e.status = EventStatus::Suggested; + e.timestamp = b.timestamp.clone(); + e.meta = serde_json::json!({ + "dream_run_id": run_id, + "session_id": session_id, + "backfilled": true, + }); + e +} + #[cfg(test)] mod tests { use super::*; @@ -78,4 +99,18 @@ mod tests { fn disjoint_text_is_similarity_zero() { assert_eq!(similarity("alpha beta", "gamma delta"), 0.0); } + + #[test] + fn to_event_stamps_dream_provenance() { + let b = ev("New constraint discovered"); + let e = to_event(&b, "run-1", "sess-9"); + assert_eq!(e.source, crate::event::Source::Dream); + assert_eq!(e.author, crate::event::Author::Agent); + assert_eq!(e.status, crate::event::EventStatus::Suggested); + assert_eq!(e.timestamp, "2026-06-08T10:00:00Z"); + assert_eq!(e.meta["session_id"], serde_json::json!("sess-9")); + assert_eq!(e.meta["dream_run_id"], serde_json::json!("run-1")); + assert_eq!(e.meta["backfilled"], serde_json::json!(true)); + assert_eq!(e.task_id, "tj-1"); + } } From 3b9b59282f1950fb6b34633e68b938b28b543d7e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:44:18 +0400 Subject: [PATCH 15/57] feat(dream): run_dream orchestration with dry-run + indexing Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/dream/mod.rs | 132 ++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index dd54dcc..c8b9b54 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -10,3 +10,135 @@ pub mod http; pub mod prompt; pub mod scope; pub mod state; + +use crate::dream::backend::{BackfillInput, DreamBackend}; + +pub struct DreamOptions { + pub project_hash: String, + /// If true, do not call the backend or write anything; report scope only. + pub dry_run: bool, +} + +#[derive(Debug, Default, PartialEq)] +pub struct DreamReport { + pub sessions_processed: usize, + pub events_backfilled: usize, +} + +/// Run one dream Pass A over the given sessions, using the supplied +/// backend. `sessions` is a list of (session_id, BackfillInput) the +/// caller has already assembled from transcripts + existing events. +pub fn run_dream( + conn: &rusqlite::Connection, + events_path: &std::path::Path, + opts: &DreamOptions, + backend: &dyn DreamBackend, + sessions: Vec<(String, BackfillInput)>, + run_id: &str, +) -> anyhow::Result { + let mut report = DreamReport::default(); + for (session_id, input) in sessions { + report.sessions_processed += 1; + if opts.dry_run { + continue; + } + let proposed = backend.backfill(&input)?; + // Flatten existing texts across candidate tasks for the guard. + let existing: Vec = input + .tasks + .iter() + .flat_map(|t| t.existing_events.clone()) + .collect(); + let kept = crate::dream::backfill::dedup_guard(proposed, &existing); + let mut writer = crate::storage::JsonlWriter::open(events_path)?; + for b in &kept { + let e = crate::dream::backfill::to_event(b, run_id, &session_id); + writer.append(&e)?; + crate::db::upsert_task_from_event(conn, &e, &opts.project_hash)?; + crate::db::index_event(conn, &e)?; + report.events_backfilled += 1; + } + writer.flush_durable()?; + } + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dream::backend::{BackfillEvent, BackfillTaskContext, MockDreamBackend}; + use crate::event::{Author, Event, EventType, Source}; + use tempfile::TempDir; + + fn task_input() -> (String, BackfillInput) { + ( + "sess-1".to_string(), + BackfillInput { + tasks: vec![BackfillTaskContext { + task_id: "tj-1".into(), + title: "Demo".into(), + existing_events: vec!["Already known fact.".into()], + }], + transcript: "user: ...\nassistant: ...".into(), + }, + ) + } + + #[test] + fn run_dream_appends_novel_events_and_indexes() { + let d = TempDir::new().unwrap(); + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + let events_path = d.path().join("events.jsonl"); + + // Seed the task so upsert/index has a home (open event). + let open = Event::new("tj-1", EventType::Open, Author::User, Source::Cli, "Demo".into()); + crate::db::upsert_task_from_event(&conn, &open, "ph").unwrap(); + + let backend = MockDreamBackend { + events: vec![ + BackfillEvent { + event_type: EventType::Finding, + task_id: "tj-1".into(), + text: "A brand new finding.".into(), + timestamp: "2026-06-08T10:00:00Z".into(), + }, + BackfillEvent { + event_type: EventType::Finding, + task_id: "tj-1".into(), + text: "Already known fact.".into(), // dup → dropped + timestamp: "2026-06-08T10:01:00Z".into(), + }, + ], + }; + let opts = DreamOptions { + project_hash: "ph".into(), + dry_run: false, + }; + let report = + run_dream(&conn, &events_path, &opts, &backend, vec![task_input()], "run-1").unwrap(); + + assert_eq!(report.sessions_processed, 1); + assert_eq!(report.events_backfilled, 1); // dup dropped + let body = std::fs::read_to_string(&events_path).unwrap(); + assert!(body.contains("A brand new finding.")); + assert!(body.contains("\"source\":\"dream\"")); + assert!(!body.contains("\"text\":\"Already known fact.\",\"refs\"")); + } + + #[test] + fn dry_run_writes_nothing_and_skips_backend() { + let d = TempDir::new().unwrap(); + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + let events_path = d.path().join("events.jsonl"); + let backend = MockDreamBackend { events: vec![] }; + let opts = DreamOptions { + project_hash: "ph".into(), + dry_run: true, + }; + let report = + run_dream(&conn, &events_path, &opts, &backend, vec![task_input()], "run-1").unwrap(); + assert_eq!(report.sessions_processed, 1); + assert_eq!(report.events_backfilled, 0); + assert!(!events_path.exists()); + } +} From c55ffec4b011825efc0cdc59e75e673f407b14fd Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:47:16 +0400 Subject: [PATCH 16/57] feat(dream): CLI dream subcommand with --dry-run/--since/--task/--limit Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 236 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 3be3306..287bafd 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -756,6 +756,22 @@ enum Commands { #[arg(long)] project: Option, }, + /// Offline memory backfill: re-read session transcripts and append + /// significant events the realtime classifier missed (dream Pass A). + Dream { + /// Only sessions in the last N days (overrides the watermark). + #[arg(long)] + since: Option, + /// Only this task's sessions. + #[arg(long)] + task: Option, + /// Show scope without calling the API or writing anything. + #[arg(long)] + dry_run: bool, + /// Cap sessions processed this run. + #[arg(long)] + limit: Option, + }, /// Export tasks as Markdown or JSON to stdout. Export { /// Output format: md, json. @@ -2180,6 +2196,80 @@ fn main() -> Result<()> { Commands::ClassifyWorker { backend } => { run_classify_worker(&backend)?; } + Commands::Dream { + since, + task, + dry_run, + limit, + } => { + 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)?; + + // 1. Resolve session files in scope. + let project_dir = tj_core::session::discovery::find_project_dir(&cwd)?; + let Some(project_dir) = project_dir else { + println!("dream: no Claude Code sessions found for this project"); + return Ok(()); + }; + let session_paths = tj_core::session::discovery::list_sessions(&project_dir)?; + + let since_time = if let Some(days) = since { + Some( + std::time::SystemTime::now() + - std::time::Duration::from_secs((days.max(0) as u64) * 86_400), + ) + } else { + // Watermark → SystemTime. Absent watermark = all sessions. + match tj_core::dream::state::last_dream_at(&conn, &project_hash)? { + Some(ts) => chrono::DateTime::parse_from_rfc3339(&ts) + .ok() + .map(std::time::SystemTime::from), + None => None, + } + }; + + 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, since_time, limit); + + // 2. Assemble (session_id, BackfillInput) per session. + let run_id = ulid::Ulid::new().to_string(); + let sessions = build_dream_inputs(&events_path, &in_scope, task.as_deref())?; + + // 3. Run. + let opts = tj_core::dream::DreamOptions { + project_hash: project_hash.clone(), + dry_run, + }; + if dry_run { + println!("dream (dry-run): {} session(s) in scope", sessions.len()); + return Ok(()); + } + let backend = tj_core::dream::http::AnthropicDreamBackend::from_env()?; + let report = + tj_core::dream::run_dream(&conn, &events_path, &opts, &backend, sessions, &run_id)?; + + // 4. Advance watermark to now (only reached on success). + tj_core::dream::state::set_last_dream_at( + &conn, + &project_hash, + &chrono::Utc::now().to_rfc3339(), + )?; + println!( + "dream: {} session(s) processed, {} event(s) backfilled", + report.sessions_processed, report.events_backfilled + ); + } Commands::Export { format, task, @@ -3698,6 +3788,152 @@ fn parse_event_type(s: &str) -> anyhow::Result { }) } +/// Flatten a parsed session transcript into role-tagged turns, in order. +fn flatten_transcript(parsed: &tj_core::session::parser::ParsedSession) -> String { + use tj_core::session::parser::{extract_assistant_texts, extract_user_text, SessionEntry}; + let mut s = String::new(); + for entry in &parsed.entries { + match entry { + SessionEntry::User(u) => { + if let Some(text) = extract_user_text(u) { + s.push_str("user: "); + s.push_str(&text); + s.push('\n'); + } + } + SessionEntry::Assistant(a) => { + for text in extract_assistant_texts(a) { + s.push_str("assistant: "); + s.push_str(&text); + s.push('\n'); + } + } + _ => {} + } + } + s +} + +/// True when any of `events` ties this task to the session: precise match +/// on `meta.session_id`, or (for legacy events with no session_id) a +/// timestamp falling inside the session's `[first_ts, last_ts]` window. +fn task_matches_session( + events: &[tj_core::event::Event], + session_id: &str, + first_ts: Option<&str>, + last_ts: Option<&str>, +) -> bool { + events.iter().any(|e| { + // Precise: event tagged with this session. + if e.meta.get("session_id").and_then(|v| v.as_str()) == Some(session_id) { + return true; + } + // Legacy fallback: timestamp inside the session window. + if e.meta.get("session_id").is_none() { + if let (Some(f), Some(l)) = (first_ts, last_ts) { + return e.timestamp.as_str() >= f && e.timestamp.as_str() <= l; + } + } + false + }) +} + +/// Read the project's events from `events_path`, group by `task_id`, and +/// return candidate task contexts for sessions whose events match this +/// session (precise session_id, or legacy time-window). Each context +/// carries the task title and up to the last ~20 event texts (dedup +/// context for the backend). +fn candidate_tasks_for_session( + events_path: &std::path::Path, + session_id: &str, + first_ts: Option<&str>, + last_ts: Option<&str>, +) -> anyhow::Result> { + use std::collections::BTreeMap; + use tj_core::dream::backend::BackfillTaskContext; + use tj_core::event::{Event, EventType}; + + if !events_path.exists() { + return Ok(Vec::new()); + } + let body = std::fs::read_to_string(events_path)?; + let mut by_task: BTreeMap> = BTreeMap::new(); + for line in body.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(e) = serde_json::from_str::(line) { + by_task.entry(e.task_id.clone()).or_default().push(e); + } + } + + let mut out = Vec::new(); + for (task_id, events) in by_task { + if !task_matches_session(&events, session_id, first_ts, last_ts) { + continue; + } + // Title from the Open event when present, else the first event's text. + let title = events + .iter() + .find(|e| e.event_type == EventType::Open) + .or_else(|| events.first()) + .map(|e| e.text.clone()) + .unwrap_or_default(); + let existing_events: Vec = events + .iter() + .rev() + .take(20) + .rev() + .map(|e| e.text.clone()) + .collect(); + out.push(BackfillTaskContext { + task_id, + title, + existing_events, + }); + } + Ok(out) +} + +/// Assemble per-session `(session_id, BackfillInput)` from the in-scope +/// session transcripts and the project's existing events. +fn build_dream_inputs( + events_path: &std::path::Path, + sessions: &[std::path::PathBuf], + task_filter: Option<&str>, +) -> anyhow::Result> { + use tj_core::dream::backend::BackfillInput; + use tj_core::session::parser::parse_session; + + let mut out = Vec::new(); + for path in sessions { + let session_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let parsed = parse_session(path)?; + + let candidates = candidate_tasks_for_session( + events_path, + &session_id, + parsed.first_timestamp.as_deref(), + parsed.last_timestamp.as_deref(), + )?; + let tasks: Vec<_> = candidates + .into_iter() + .filter(|t| task_filter.map_or(true, |f| f == t.task_id)) + .collect(); + if tasks.is_empty() { + continue; + } + + let transcript = flatten_transcript(&parsed); + out.push((session_id, BackfillInput { tasks, transcript })); + } + Ok(out) +} + #[cfg(test)] mod inline_tests { // Sits at the bottom of the file to satisfy From b3913821e63814b9a33b68913bdf77785153ce50 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:47:48 +0400 Subject: [PATCH 17/57] =?UTF-8?q?feat(dream):=20tested=20session=E2=86=92t?= =?UTF-8?q?ask=20mapping=20+=20transcript=20flattening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 287bafd..102078b 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -3941,6 +3941,48 @@ mod inline_tests { // declared before this module begins. use super::*; + #[test] + fn flatten_transcript_tags_roles_in_order() { + use tj_core::session::parser::parse_session; + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("sess-1.jsonl"); + std::fs::write(&p, + "{\"type\":\"user\",\"uuid\":\"u1\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"message\":{\"content\":\"why?\"}}\n\ + {\"type\":\"assistant\",\"uuid\":\"a1\",\"timestamp\":\"2026-01-01T00:00:01Z\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"because X\"}]}}\n").unwrap(); + let parsed = parse_session(&p).unwrap(); + let t = flatten_transcript(&parsed); + let u = t.find("why?").unwrap(); + let a = t.find("because X").unwrap(); + assert!(u < a, "user turn should precede assistant turn"); + } + + #[test] + fn task_matches_by_session_id_or_time_window() { + use tj_core::event::{Author, Event, EventType, Source}; + let mut tagged = + Event::new("tj-1", EventType::Finding, Author::Agent, Source::Hook, "x".into()); + tagged.meta = serde_json::json!({"session_id": "sess-1"}); + assert!(task_matches_session(&[tagged], "sess-1", None, None)); + + let mut legacy = + Event::new("tj-2", EventType::Finding, Author::Agent, Source::Hook, "y".into()); + legacy.timestamp = "2026-01-01T00:00:30Z".into(); + legacy.meta = serde_json::json!({}); // no session_id + assert!(task_matches_session( + &[legacy.clone()], + "sess-1", + Some("2026-01-01T00:00:00Z"), + Some("2026-01-01T00:01:00Z"), + )); + // Outside the window and no session id → no match. + assert!(!task_matches_session( + &[legacy], + "sess-1", + Some("2026-02-01T00:00:00Z"), + Some("2026-02-01T00:01:00Z"), + )); + } + #[test] fn persist_pending_v2_includes_session_id_when_present() { let dir = tempfile::tempdir().unwrap(); From a1932e70702ac598f8f04e38caa23038fb46a7a4 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:48:17 +0400 Subject: [PATCH 18/57] docs(dream): add dream backfill entry under 0.12.0 CHANGELOG Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3045009..ee773b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 evidence, unconfirmed suggested events, missing goal, unclassified pending entries) — shown only when gaps exist. Read-only; reusable `completeness::assess` API for the upcoming close-gate. +- `task-journal dream` — offline memory backfill (Pass A). Re-reads session + transcripts and appends significant typed events the realtime classifier + missed, stamped `source=dream`, `status=suggested` (visible, prunable). + Manual trigger; `--dry-run`, `--since`, `--task`, `--limit`. Reuses the + Anthropic HTTP backend via `TJ_DREAM_MODEL` / `TJ_DREAM_MAX_TOKENS`. + Additive — the JSONL source of truth is never mutated. ## [0.11.1] - 2026-06-08 From a02f7c445c62fafea7959f1038a99fa50904424e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:51:09 +0400 Subject: [PATCH 19/57] feat(hierarchy): add tasks.parent_id column + index --- crates/tj-core/src/db.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 2271f5d..ca8241e 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -110,6 +110,14 @@ CREATE TABLE IF NOT EXISTS dream_state ( ); "#; +/// v0.12.0 subtask hierarchy — nullable `parent_id` carries the parent +/// task on the `open` event's `meta.parent_id`. Existing flat tasks stay +/// NULL. Index supports `children_of` lookups. +const MIGRATION_006: &str = r#" +ALTER TABLE tasks ADD COLUMN parent_id TEXT; +CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); +"#; + /// All schema migrations in version order. Append new entries here; never /// edit a published migration's `sql` — write a new one instead. const MIGRATIONS: &[Migration] = &[ @@ -133,6 +141,10 @@ const MIGRATIONS: &[Migration] = &[ version: 5, sql: MIGRATION_005, }, + Migration { + version: 6, + sql: MIGRATION_006, + }, ]; fn apply_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -1449,4 +1461,23 @@ mod tests { assert_eq!(title, "Add OAuth login"); assert_eq!(status, "open"); } + + #[test] + fn migration_adds_parent_id_column_nullable() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + + // Seed a task via an open event (no parent). + let e = make_open_event("tj-a", "Top"); + upsert_task_from_event(&conn, &e, "ph").unwrap(); + + let parent: Option = conn + .query_row( + "SELECT parent_id FROM tasks WHERE task_id = ?1", + rusqlite::params!["tj-a"], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(parent, None); + } } From 0c8c88ad81027719c36079b4491d85891843412b Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:51:46 +0400 Subject: [PATCH 20/57] feat(hierarchy): persist parent_id from open event meta --- crates/tj-core/src/db.rs | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index ca8241e..b5e301a 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -205,11 +205,18 @@ pub fn upsert_task_from_event( .and_then(|v| v.as_str()) .unwrap_or(&event.text) .to_string(); + let parent_id = event + .meta + .get("parent_id") + .and_then(|v| v.as_str()) + .map(str::to_string); + // ON CONFLICT intentionally does not overwrite parent_id — parent + // is set once at creation; re-parenting is a separate future path. conn.execute( - "INSERT INTO tasks(task_id, title, status, project_hash, opened_at, last_event_at) - VALUES (?1, ?2, 'open', ?3, ?4, ?4) + "INSERT INTO tasks(task_id, title, status, project_hash, opened_at, last_event_at, parent_id) + VALUES (?1, ?2, 'open', ?3, ?4, ?4, ?5) ON CONFLICT(task_id) DO UPDATE SET last_event_at = ?4", - rusqlite::params![event.task_id, title, project_hash, event.timestamp], + rusqlite::params![event.task_id, title, project_hash, event.timestamp, parent_id], )?; } EventType::Close => { @@ -1480,4 +1487,27 @@ mod tests { .unwrap(); assert_eq!(parent, None); } + + #[test] + fn open_event_meta_parent_id_is_persisted() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + + // Parent first. + upsert_task_from_event(&conn, &make_open_event("tj-parent", "Parent"), "ph").unwrap(); + + // Child carries meta.parent_id. + let mut child = make_open_event("tj-child", "Child"); + child.meta = serde_json::json!({"title": "Child", "parent_id": "tj-parent"}); + upsert_task_from_event(&conn, &child, "ph").unwrap(); + + let parent: Option = conn + .query_row( + "SELECT parent_id FROM tasks WHERE task_id = ?1", + rusqlite::params!["tj-child"], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(parent.as_deref(), Some("tj-parent")); + } } From 9fbf1edbcdef23b571503f815a654b9aa800d1cb Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:52:19 +0400 Subject: [PATCH 21/57] feat(hierarchy): children_of + parent_of queries --- crates/tj-core/src/db.rs | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index b5e301a..958ffd5 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -907,6 +907,42 @@ pub fn list_tasks_by_project( Ok(rows) } +/// Direct children of a task (one level), newest activity first. +pub fn children_of(conn: &Connection, task_id: &str) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT t.task_id, t.title, t.status, t.last_event_at, + COALESCE(c.cnt, 0) AS event_count + FROM tasks t + LEFT JOIN ( + SELECT task_id, COUNT(*) AS cnt FROM events_index GROUP BY task_id + ) c ON c.task_id = t.task_id + WHERE t.parent_id = ?1 + ORDER BY (t.status = 'open') DESC, t.last_event_at DESC", + )?; + let rows = stmt + .query_map(rusqlite::params![task_id], |r| { + Ok(TaskRow { + task_id: r.get::<_, String>(0)?, + title: r.get::<_, String>(1)?, + status: r.get::<_, String>(2)?, + last_event_at: r.get::<_, String>(3)?, + event_count: r.get::<_, i64>(4)? as usize, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +/// The stored parent of a task, if any. +pub fn parent_of(conn: &Connection, task_id: &str) -> anyhow::Result> { + let mut stmt = conn.prepare("SELECT parent_id FROM tasks WHERE task_id = ?1")?; + let mut rows = stmt.query(rusqlite::params![task_id])?; + Ok(match rows.next()? { + Some(r) => r.get::<_, Option>(0)?, + None => None, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1510,4 +1546,26 @@ mod tests { .unwrap(); assert_eq!(parent.as_deref(), Some("tj-parent")); } + + #[test] + fn children_of_and_parent_of_work() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + upsert_task_from_event(&conn, &make_open_event("p", "Parent"), "ph").unwrap(); + + let mut c1 = make_open_event("c1", "Child1"); + c1.meta = serde_json::json!({"title": "Child1", "parent_id": "p"}); + upsert_task_from_event(&conn, &c1, "ph").unwrap(); + let mut c2 = make_open_event("c2", "Child2"); + c2.meta = serde_json::json!({"title": "Child2", "parent_id": "p"}); + upsert_task_from_event(&conn, &c2, "ph").unwrap(); + + let kids = children_of(&conn, "p").unwrap(); + let ids: Vec<&str> = kids.iter().map(|t| t.task_id.as_str()).collect(); + assert!(ids.contains(&"c1") && ids.contains(&"c2")); + assert_eq!(kids.len(), 2); + + assert_eq!(parent_of(&conn, "c1").unwrap().as_deref(), Some("p")); + assert_eq!(parent_of(&conn, "p").unwrap(), None); + } } From 78652a52dd0121a163400b9e2b6a9ad3706b3b2b Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:52:51 +0400 Subject: [PATCH 22/57] feat(hierarchy): would_create_cycle guard --- crates/tj-core/src/db.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 958ffd5..5706736 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -943,6 +943,32 @@ pub fn parent_of(conn: &Connection, task_id: &str) -> anyhow::Result anyhow::Result { + if task_id == new_parent { + return Ok(true); + } + let mut cursor = Some(new_parent.to_string()); + for _ in 0..64 { + let Some(cur) = cursor else { + return Ok(false); + }; + if cur == task_id { + return Ok(true); + } + cursor = parent_of(conn, &cur)?; + } + // Depth cap exceeded — treat as a cycle to be safe. + Ok(true) +} + #[cfg(test)] mod tests { use super::*; @@ -1568,4 +1594,22 @@ mod tests { assert_eq!(parent_of(&conn, "c1").unwrap().as_deref(), Some("p")); assert_eq!(parent_of(&conn, "p").unwrap(), None); } + + #[test] + fn cycle_guard_rejects_self_and_ancestor() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + upsert_task_from_event(&conn, &make_open_event("a", "A"), "ph").unwrap(); + let mut b = make_open_event("b", "B"); + b.meta = serde_json::json!({"title": "B", "parent_id": "a"}); + upsert_task_from_event(&conn, &b, "ph").unwrap(); + + // a is b's ancestor → making a a child of b is a cycle. + assert!(would_create_cycle(&conn, "a", "b").unwrap()); + // self-parent is a cycle. + assert!(would_create_cycle(&conn, "a", "a").unwrap()); + // unrelated parent is fine. + upsert_task_from_event(&conn, &make_open_event("x", "X"), "ph").unwrap(); + assert!(!would_create_cycle(&conn, "x", "a").unwrap()); + } } From 09e521b4176e6a21df1a06fe12ad2d15e5dd435b Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:53:56 +0400 Subject: [PATCH 23/57] feat(hierarchy): cascade pack-cache invalidation to parent --- crates/tj-core/src/db.rs | 55 +++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 5706736..1d1e54a 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -630,10 +630,7 @@ pub fn reclassify_task_artifacts(conn: &Connection, task_id: &str) -> anyhow::Re rusqlite::params![json, event_id], )?; } - conn.execute( - "DELETE FROM task_pack_cache WHERE task_id = ?1", - rusqlite::params![task_id], - )?; + invalidate_pack_cascade(conn, task_id)?; Ok(count) } @@ -844,11 +841,9 @@ pub fn index_event(conn: &Connection, event: &Event) -> anyhow::Result<()> { )?; } - // Invalidate any cached pack for this task. - conn.execute( - "DELETE FROM task_pack_cache WHERE task_id=?1", - rusqlite::params![event.task_id], - )?; + // Invalidate any cached pack for this task — and its parent, whose + // Subtasks roll-up depends on this child. + invalidate_pack_cascade(conn, &event.task_id)?; Ok(()) } @@ -969,6 +964,21 @@ pub fn would_create_cycle( Ok(true) } +/// Clear the pack cache for a task and its parent (roll-up depends on both). +pub fn invalidate_pack_cascade(conn: &Connection, task_id: &str) -> anyhow::Result<()> { + conn.execute( + "DELETE FROM task_pack_cache WHERE task_id = ?1", + rusqlite::params![task_id], + )?; + if let Some(parent) = parent_of(conn, task_id)? { + conn.execute( + "DELETE FROM task_pack_cache WHERE task_id = ?1", + rusqlite::params![parent], + )?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1612,4 +1622,31 @@ mod tests { upsert_task_from_event(&conn, &make_open_event("x", "X"), "ph").unwrap(); assert!(!would_create_cycle(&conn, "x", "a").unwrap()); } + + #[test] + fn invalidate_cascade_clears_parent_pack() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + upsert_task_from_event(&conn, &make_open_event("p", "P"), "ph").unwrap(); + let mut c = make_open_event("c", "C"); + c.meta = serde_json::json!({"title": "C", "parent_id": "p"}); + upsert_task_from_event(&conn, &c, "ph").unwrap(); + + // Seed pack cache rows for both. + for id in ["p", "c"] { + conn.execute( + "INSERT INTO task_pack_cache(task_id, mode, text, generated_at, source_event_count) + VALUES (?1, 'compact', 'x', '2026-01-01T00:00:00Z', 1)", + rusqlite::params![id], + ) + .unwrap(); + } + + invalidate_pack_cascade(&conn, "c").unwrap(); + + let remaining: i64 = conn + .query_row("SELECT COUNT(*) FROM task_pack_cache", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 0, "both child and parent pack caches cleared"); + } } From 2b842a4d52b6b44f39088918595f557426f8343b Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:54:59 +0400 Subject: [PATCH 24/57] feat(hierarchy): roll up direct children in parent pack --- crates/tj-core/src/pack.rs | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index a80bf6f..417db02 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -168,6 +168,20 @@ fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result /// boundary and preferring the last newline within the kept prefix, then /// append `marker`. Char-boundary-safe: a raw `text[..budget]` byte slice /// panics when `budget` lands inside a multibyte char (Cyrillic/CJK/emoji). +/// Render a compact one-level roll-up of a task's direct children, or None +/// when it has no children. Each child: status, id, title. Bounded. +fn render_subtasks(conn: &Connection, task_id: &str) -> anyhow::Result> { + let kids = crate::db::children_of(conn, task_id)?; + if kids.is_empty() { + return Ok(None); + } + let mut s = format!("\n## Subtasks ({})\n", kids.len()); + for k in &kids { + s.push_str(&format!("- [{}] {} — {}\n", k.status, k.task_id, k.title)); + } + Ok(Some(s)) +} + fn truncate_to_budget(text: &mut String, budget: usize, marker: &str) { if text.len() <= budget { return; @@ -342,6 +356,13 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res text.push_str(§ion); } + // One-level roll-up of direct children (parents only). Appended before + // truncation so it shares the pack budget. Task 5 busts the parent cache + // when a child changes, so the next assemble regenerates fresh. + if let Some(subtasks) = render_subtasks(conn, task_id)? { + text.push_str(&subtasks); + } + // Token-budget truncation: cap pack size so it always fits an LLM context window. // v0.10.3: full bumped 10K → 24K. Real tasks accumulate 50-100 events // and the prior cap clipped final-summary decisions even after the @@ -392,6 +413,43 @@ mod tests { assert_eq!(s, "\"Compact\""); } + #[test] + fn parent_pack_contains_subtasks_section() { + let d = tempfile::TempDir::new().unwrap(); + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + + // Parent + one child, each with an open event. + let mut p = crate::event::Event::new( + "p", + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "Parent".into(), + ); + p.meta = serde_json::json!({"title": "Parent"}); + crate::db::upsert_task_from_event(&conn, &p, "ph").unwrap(); + crate::db::index_event(&conn, &p).unwrap(); + + let mut c = crate::event::Event::new( + "c", + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "Child".into(), + ); + c.meta = serde_json::json!({"title": "Child", "parent_id": "p"}); + crate::db::upsert_task_from_event(&conn, &c, "ph").unwrap(); + crate::db::index_event(&conn, &c).unwrap(); + + let parent_pack = assemble(&conn, "p", PackMode::Compact).unwrap(); + assert!(parent_pack.text.contains("Subtasks")); + assert!(parent_pack.text.contains("Child")); + assert!(parent_pack.text.contains("c")); // child id + + let child_pack = assemble(&conn, "c", PackMode::Compact).unwrap(); + assert!(!child_pack.text.contains("Subtasks")); + } + #[test] fn pack_shows_completeness_section_when_gaps() { let d = tempfile::TempDir::new().unwrap(); From a71646a7439ce3e8913f97b8e05af7035f935a0e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:56:49 +0400 Subject: [PATCH 25/57] feat(hierarchy): CLI create --parent with validation --- crates/tj-cli/src/main.rs | 27 +++++++++++++- crates/tj-cli/tests/cli.rs | 76 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 102078b..c23cd19 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -595,6 +595,9 @@ enum Commands { /// with `task-journal goal ""`. #[arg(long)] goal: Option, + /// Parent task id — makes this a subtask of . + #[arg(long)] + parent: Option, }, /// Inspect events for a project. Events { @@ -926,6 +929,7 @@ fn main() -> Result<()> { title, context, goal, + parent, } => { let cwd = std::env::current_dir()?; let project_hash = tj_core::project_hash::from_path(&cwd)?; @@ -934,6 +938,23 @@ fn main() -> Result<()> { std::fs::create_dir_all(&events_dir)?; let task_id = tj_core::new_task_id(); + + // Validate --parent before writing the open event: the parent must + // already exist and the link must not introduce a cycle. Needs the + // derived SQLite state, so ingest the JSONL tail first. + if let Some(ref parent_id) = parent { + let state_path = + tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + let conn = tj_core::db::open(&state_path)?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + if !tj_core::db::task_exists(&conn, parent_id)? { + anyhow::bail!("parent task {parent_id} does not exist"); + } + if tj_core::db::would_create_cycle(&conn, &task_id, parent_id)? { + anyhow::bail!("setting parent {parent_id} would create a cycle"); + } + } + let mut event = tj_core::event::Event::new( task_id.clone(), tj_core::event::EventType::Open, @@ -941,7 +962,11 @@ fn main() -> Result<()> { tj_core::event::Source::Cli, context.clone().unwrap_or_else(|| title.clone()), ); - event.meta = serde_json::json!({ "title": title }); + let mut meta = serde_json::json!({ "title": title }); + if let Some(ref parent_id) = parent { + meta["parent_id"] = serde_json::Value::String(parent_id.clone()); + } + event.meta = meta; let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 7a36ac3..604ec74 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -3748,3 +3748,79 @@ fn precompact_dedupes_marker_within_60s_window() { "second PreCompact within DEDUP_WINDOW must NOT emit a new event id; got: {second_out:?}" ); } + +/// Read the single events JSONL file under /task-journal/events. +fn read_events_jsonl(xdg: &std::path::Path) -> String { + let events_dir = xdg.join("task-journal").join("events"); + let entry = std::fs::read_dir(&events_dir) + .unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.path().extension().and_then(|x| x.to_str()) == Some("jsonl")) + .expect("an events jsonl file"); + std::fs::read_to_string(entry.path()).unwrap() +} + +#[test] +fn create_with_parent_sets_parent_id() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + + // Parent task. + let parent_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Parent"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + // Child with --parent. + let child_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Child", "--parent", &parent_id]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + // The child's open event in the JSONL carries meta.parent_id == parent. + let jsonl = read_events_jsonl(xdg.path()); + let child_open = jsonl + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .find(|v| v.get("task_id").and_then(|x| x.as_str()) == Some(&child_id)) + .expect("child open event"); + assert_eq!( + child_open["meta"]["parent_id"].as_str(), + Some(parent_id.as_str()) + ); +} + +#[test] +fn create_with_missing_parent_is_rejected() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Child", "--parent", "tj-nope"]) + .assert() + .failure(); +} From 4e675da7859940e80f5d30d776b81db8a266d23d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 08:59:09 +0400 Subject: [PATCH 26/57] feat(hierarchy): MCP task_create parent param --- crates/tj-mcp/src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 8a73ca1..e63993a 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -202,6 +202,9 @@ pub struct TaskCreateParams { /// "why was this done?" answers weeks later. Optional only for /// backwards compat; agents should always pass it. pub goal: Option, + /// Parent task id — makes this a subtask of . Validated: the + /// parent must exist and the link must not introduce a cycle. + pub parent: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct TaskCreateResult { @@ -423,6 +426,24 @@ impl TaskJournalServer { std::fs::create_dir_all(events_path.parent().unwrap())?; let task_id = tj_core::new_task_id(); + + // 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. + if let Some(ref parent_id) = p.parent { + 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 !tj_core::db::task_exists(&conn, parent_id)? { + anyhow::bail!("parent task {parent_id} does not exist"); + } + if tj_core::db::would_create_cycle(&conn, &task_id, parent_id)? { + anyhow::bail!("setting parent {parent_id} would create a cycle"); + } + } + let mut event = tj_core::event::Event::new( task_id.clone(), tj_core::event::EventType::Open, @@ -431,6 +452,9 @@ impl TaskJournalServer { p.initial_context.clone().unwrap_or_else(|| p.title.clone()), ); event.meta = serde_json::json!({"title": p.title.clone()}); + if let Some(ref parent_id) = p.parent { + event.meta["parent_id"] = serde_json::Value::String(parent_id.clone()); + } tj_core::session_id::stamp_session_id( &mut event.meta, tj_core::session_id::session_id_from_env().as_deref(), @@ -850,6 +874,59 @@ mod tests { assert!(cli.project_dir.is_none()); } + #[tokio::test] + async fn task_create_with_parent_stamps_meta() { + // Isolate state under a temp XDG home and a unique project dir + // (set once via PROJECT_DIR_OVERRIDE). Create a parent, then a child + // with parent = Some(parent_id); assert the child's open event in the + // JSONL carries meta.parent_id. + let xdg = tempfile::TempDir::new().unwrap(); + let proj = tempfile::TempDir::new().unwrap(); + std::env::set_var("XDG_DATA_HOME", xdg.path()); + // OnceLock — only this test sets it; ignore if a prior test already did. + let _ = PROJECT_DIR_OVERRIDE.set(proj.path().to_path_buf()); + + let server = TaskJournalServer; + + let parent = server + .task_create(Parameters(TaskCreateParams { + title: "Parent".into(), + initial_context: None, + goal: None, + parent: None, + })) + .await + .unwrap() + .0 + .task_id; + + let child = server + .task_create(Parameters(TaskCreateParams { + title: "Child".into(), + initial_context: None, + goal: None, + parent: Some(parent.clone()), + })) + .await + .unwrap() + .0 + .task_id; + + let (_, events_path, _) = resolve_project_paths(proj.path()).unwrap(); + let jsonl = std::fs::read_to_string(&events_path).unwrap(); + let child_open = jsonl + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .find(|v| v.get("task_id").and_then(|x| x.as_str()) == Some(child.as_str())) + .expect("child open event"); + assert_eq!( + child_open["meta"]["parent_id"].as_str(), + Some(parent.as_str()) + ); + + std::env::remove_var("XDG_DATA_HOME"); + } + #[test] fn into_mcp_error_carries_full_anyhow_chain() { // Down-stream callers rely on McpError.message containing the full From 4463fb4963111a0e303c62c2caae3db8cb83b313 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:04:04 +0400 Subject: [PATCH 27/57] feat(hierarchy): warn on closing a task with open subtasks --- crates/tj-cli/src/main.rs | 4 ++ crates/tj-core/src/db.rs | 35 ++++++++++++++++ crates/tj-mcp/src/main.rs | 85 ++++++++++++++++++++++++++++++++++----- 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index c23cd19..db6dbc0 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1113,6 +1113,7 @@ fn main() -> Result<()> { if let Some(o) = outcome.as_deref() { tj_core::db::set_task_outcome(&conn, &task_id, o, outcome_tag.as_deref())?; } + let open_kids = tj_core::db::count_open_children(&conn, &task_id)?; drop(conn); let mut event = tj_core::event::Event::new( @@ -1129,6 +1130,9 @@ fn main() -> Result<()> { let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; writer.flush_durable()?; + if open_kids > 0 { + eprintln!("note: {open_kids} open subtask(s) under {task_id}"); + } println!("{}", event.event_id); } Commands::Stale { days } => { diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 1d1e54a..cd6ae4f 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -964,6 +964,16 @@ pub fn would_create_cycle( Ok(true) } +/// Number of direct children of `task_id` whose status is still open. +pub fn count_open_children(conn: &Connection, task_id: &str) -> anyhow::Result { + let n: i64 = conn.query_row( + "SELECT COUNT(*) FROM tasks WHERE parent_id = ?1 AND status = 'open'", + rusqlite::params![task_id], + |r| r.get(0), + )?; + Ok(n as usize) +} + /// Clear the pack cache for a task and its parent (roll-up depends on both). pub fn invalidate_pack_cascade(conn: &Connection, task_id: &str) -> anyhow::Result<()> { conn.execute( @@ -1649,4 +1659,29 @@ mod tests { .unwrap(); assert_eq!(remaining, 0, "both child and parent pack caches cleared"); } + + #[test] + fn count_open_children_counts_only_open() { + let d = tempfile::TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + upsert_task_from_event(&conn, &make_open_event("p", "P"), "ph").unwrap(); + let mut c1 = make_open_event("c1", "C1"); + c1.meta = serde_json::json!({"title": "C1", "parent_id": "p"}); + upsert_task_from_event(&conn, &c1, "ph").unwrap(); + // Close c1. + let mut close = crate::event::Event::new( + "c1", + crate::event::EventType::Close, + crate::event::Author::User, + crate::event::Source::Cli, + "done".into(), + ); + close.timestamp = "2026-01-02T00:00:00Z".into(); + upsert_task_from_event(&conn, &close, "ph").unwrap(); + let mut c2 = make_open_event("c2", "C2"); + c2.meta = serde_json::json!({"title": "C2", "parent_id": "p"}); + upsert_task_from_event(&conn, &c2, "ph").unwrap(); + + assert_eq!(count_open_children(&conn, "p").unwrap(), 1); // only c2 + } } diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index e63993a..96fc04d 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -240,6 +240,11 @@ pub struct TaskCloseParams { pub struct TaskCloseResult { pub task_id: String, pub closed: bool, + /// Optional advisory note — e.g. "note: N open subtask(s)" when the + /// closed task still has open children. `None` when there's nothing + /// to flag. + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, } fn parse_event_type(s: &str) -> anyhow::Result { @@ -542,10 +547,11 @@ impl TaskJournalServer { ) -> Result, McpError> { traced_tool("task_close", async move { let task_id = p.task_id.clone(); - run_blocking(move || { + let open_kids = run_blocking(move || { let (project_hash, events_path, state_path) = project_paths()?; let conn_arc = cached_open(&state_path)?; + let open_kids; { let conn = conn_arc .lock() @@ -571,6 +577,7 @@ impl TaskJournalServer { if let Some(o) = p.outcome.as_deref() { tj_core::db::set_task_outcome(&conn, &p.task_id, o, p.outcome_tag.as_deref())?; } + open_kids = tj_core::db::count_open_children(&conn, &p.task_id)?; } // release the connection lock before doing the JSONL append let mut event = tj_core::event::Event::new( @@ -597,12 +604,18 @@ impl TaskJournalServer { let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; writer.flush_durable()?; - Ok(()) + Ok(open_kids) }) .await?; + let note = if open_kids > 0 { + Some(format!("note: {open_kids} open subtask(s)")) + } else { + None + }; Ok(Json(TaskCloseResult { task_id, closed: true, + note, })) }) .await @@ -691,6 +704,23 @@ async fn main() -> Result<()> { mod tests { use super::*; + /// Handler tests touch process-global state (PROJECT_DIR_OVERRIDE OnceLock + /// + XDG_DATA_HOME env), so they must run one at a time and share a single + /// project dir. This guard serializes them and lazily pins the override + + /// XDG to a single persistent tempdir for the whole test binary. + fn handler_env() -> std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static LOCK: OnceLock> = OnceLock::new(); + static HOME: OnceLock = OnceLock::new(); + static PROJ: OnceLock = OnceLock::new(); + let guard = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(|e| e.into_inner()); + let home = HOME.get_or_init(|| tempfile::TempDir::new().unwrap()); + let proj = PROJ.get_or_init(|| tempfile::TempDir::new().unwrap()); + std::env::set_var("XDG_DATA_HOME", home.path()); + let _ = PROJECT_DIR_OVERRIDE.set(proj.path().to_path_buf()); + guard + } + fn keys_of(v: &serde_json::Value) -> Vec { v.as_object() .map(|o| o.keys().cloned().collect()) @@ -737,6 +767,7 @@ mod tests { let close = TaskCloseResult { task_id: "tj-x".into(), closed: true, + note: None, }; assert!(!keys_of(&serde_json::to_value(&close).unwrap()).contains(&"stub".to_string())); } @@ -880,12 +911,7 @@ mod tests { // (set once via PROJECT_DIR_OVERRIDE). Create a parent, then a child // with parent = Some(parent_id); assert the child's open event in the // JSONL carries meta.parent_id. - let xdg = tempfile::TempDir::new().unwrap(); - let proj = tempfile::TempDir::new().unwrap(); - std::env::set_var("XDG_DATA_HOME", xdg.path()); - // OnceLock — only this test sets it; ignore if a prior test already did. - let _ = PROJECT_DIR_OVERRIDE.set(proj.path().to_path_buf()); - + let _env = handler_env(); let server = TaskJournalServer; let parent = server @@ -912,7 +938,7 @@ mod tests { .0 .task_id; - let (_, events_path, _) = resolve_project_paths(proj.path()).unwrap(); + let (_, events_path, _) = project_paths().unwrap(); let jsonl = std::fs::read_to_string(&events_path).unwrap(); let child_open = jsonl .lines() @@ -923,8 +949,47 @@ mod tests { child_open["meta"]["parent_id"].as_str(), Some(parent.as_str()) ); + } + + #[tokio::test] + async fn task_close_notes_open_subtasks() { + let _env = handler_env(); + let server = TaskJournalServer; + + let parent = server + .task_create(Parameters(TaskCreateParams { + title: "Parent".into(), + initial_context: None, + goal: None, + parent: None, + })) + .await + .unwrap() + .0 + .task_id; - std::env::remove_var("XDG_DATA_HOME"); + // One open child under the parent. + server + .task_create(Parameters(TaskCreateParams { + title: "Child".into(), + initial_context: None, + goal: None, + parent: Some(parent.clone()), + })) + .await + .unwrap(); + + let res = server + .task_close(Parameters(TaskCloseParams { + task_id: parent.clone(), + reason: "done".into(), + outcome: None, + outcome_tag: None, + })) + .await + .unwrap() + .0; + assert_eq!(res.note.as_deref(), Some("note: 1 open subtask(s)")); } #[test] From ff275eaac6f742e757897acca0c0b3864c0ac243 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:05:33 +0400 Subject: [PATCH 28/57] feat(hierarchy): CLI list --tree --- crates/tj-cli/src/main.rs | 28 ++++++++++++++++++ crates/tj-cli/tests/cli.rs | 60 ++++++++++++++++++++++++++++++++++++++ crates/tj-core/src/db.rs | 28 ++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index db6dbc0..3cbb840 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -599,6 +599,12 @@ enum Commands { #[arg(long)] parent: Option, }, + /// List tasks for the current project. + List { + /// Render tasks as a tree, children indented under parents. + #[arg(long)] + tree: bool, + }, /// Inspect events for a project. Events { #[command(subcommand)] @@ -986,6 +992,28 @@ fn main() -> Result<()> { println!("{}", task_id); } + Commands::List { tree } => { + 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 tree { + for t in tj_core::db::top_level_tasks(&conn, &project_hash)? { + println!("{} [{}] {}", t.task_id, t.status, t.title); + for c in tj_core::db::children_of(&conn, &t.task_id)? { + println!(" {} [{}] {}", c.task_id, c.status, c.title); + } + } + } else { + for t in tj_core::db::list_tasks_by_project(&conn, &project_hash)? { + println!("{} [{}] {}", t.task_id, t.status, t.title); + } + } + } Commands::Events { action } => match action { EventsCmd::List { limit } => { let cwd = std::env::current_dir()?; diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 604ec74..21067ab 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -3824,3 +3824,63 @@ fn create_with_missing_parent_is_rejected() { .assert() .failure(); } + +#[test] +fn list_tree_indents_children_under_parents() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + + let parent_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "ParentTask"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "ChildTask", "--parent", &parent_id]) + .assert() + .success(); + + let out = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["list", "--tree"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap(); + + let parent_line = out + .lines() + .position(|l| l.contains("ParentTask")) + .expect("parent line present"); + let child_idx = out + .lines() + .position(|l| l.contains("ChildTask")) + .expect("child line present"); + // Child appears after the parent and is indented. + assert!(child_idx > parent_line, "child must come after parent"); + let child_line = out.lines().nth(child_idx).unwrap(); + assert!( + child_line.starts_with(char::is_whitespace), + "child line must be indented, got: {child_line:?}" + ); +} diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index cd6ae4f..90d991f 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -902,6 +902,34 @@ pub fn list_tasks_by_project( Ok(rows) } +/// Top-level tasks for a project (those with no parent), ordered like +/// `list_tasks_by_project` — open first, then by recency. The roots of +/// the `list --tree` view. +pub fn top_level_tasks(conn: &Connection, project_hash: &str) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT t.task_id, t.title, t.status, t.last_event_at, + COALESCE(c.cnt, 0) AS event_count + FROM tasks t + LEFT JOIN ( + SELECT task_id, COUNT(*) AS cnt FROM events_index GROUP BY task_id + ) c ON c.task_id = t.task_id + WHERE t.project_hash = ?1 AND t.parent_id IS NULL + ORDER BY (t.status = 'open') DESC, t.last_event_at DESC", + )?; + let rows = stmt + .query_map(rusqlite::params![project_hash], |r| { + Ok(TaskRow { + task_id: r.get::<_, String>(0)?, + title: r.get::<_, String>(1)?, + status: r.get::<_, String>(2)?, + last_event_at: r.get::<_, String>(3)?, + event_count: r.get::<_, i64>(4)? as usize, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + /// Direct children of a task (one level), newest activity first. pub fn children_of(conn: &Connection, task_id: &str) -> anyhow::Result> { let mut stmt = conn.prepare( From 2693de2a393e9e285ede0883f13cbcfd1dbb7366 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:06:10 +0400 Subject: [PATCH 29/57] chore(hierarchy): CHANGELOG entry for subtask hierarchy --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee773b0..73af91b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Manual trigger; `--dry-run`, `--since`, `--task`, `--limit`. Reuses the Anthropic HTTP backend via `TJ_DREAM_MODEL` / `TJ_DREAM_MAX_TOKENS`. Additive — the JSONL source of truth is never mutated. +- Subtask hierarchy: tasks can have a `parent_id`. Set at creation via + `task-journal create --parent ` or the MCP `task_create` `parent` param + (validated: parent must exist, no cycles). A parent's resume-pack rolls up its + direct children (status, id, title). `list --tree` renders the hierarchy. + Closing a parent with open children warns but proceeds. Additive — existing + flat tasks are unaffected (`parent_id` NULL). ## [0.11.1] - 2026-06-08 From 6854bf9ee4226ee49f985f4a6d207380a1506b9e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:09:20 +0400 Subject: [PATCH 30/57] feat(close-gate): CLI close prints completeness gaps (non-blocking) --- crates/tj-cli/src/main.rs | 24 +++++++++++++++++++++++ crates/tj-cli/tests/cli.rs | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 3cbb840..428971c 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1161,6 +1161,30 @@ fn main() -> Result<()> { if open_kids > 0 { eprintln!("note: {open_kids} open subtask(s) under {task_id}"); } + + // Non-blocking completeness warning. The close above already + // succeeded; re-open, apply the close event to the index, then + // assess. Any error here must NOT fail the close — handle + // locally, never `?`-propagate. + if let Ok(conn) = tj_core::db::open(&state_path) { + let _ = tj_core::db::ingest_new_events(&conn, &events_path, &project_hash); + if let Ok(report) = tj_core::completeness::assess( + &conn, + &task_id, + tj_core::completeness::pending_count(), + ) { + if !report.is_complete() { + eprintln!( + "note: task {task_id} closed with {} completeness gap(s):", + report.gaps.len() + ); + for g in &report.gaps { + eprintln!(" ⚠ {}", g.detail); + } + } + } + } + println!("{}", event.event_id); } Commands::Stale { days } => { diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 21067ab..ecb7089 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -104,6 +104,45 @@ fn close_command_marks_task_closed_in_pack() { .stdout(contains("status: closed")); } +#[test] +fn close_warns_on_completeness_gap() { + let dir = assert_fs::TempDir::new().unwrap(); + // Create a task WITH a goal so the NoGoal gap won't fire. + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["create", "Gap me", "--goal", "ship it"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + // Close WITHOUT an outcome → ClosedNoOutcome gap. The close still succeeds + // and prints the gap to stderr. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["close", &task_id, "--reason", "done"]) + .assert() + .success() + .stderr(contains("closed without a recorded outcome")); + + // And the task is actually closed. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("status: closed")); +} + #[test] fn doctor_exits_zero_on_fresh_install() { let dir = assert_fs::TempDir::new().unwrap(); From 221bacd0190371a2051a315e973a6c5d0af1a323 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:10:28 +0400 Subject: [PATCH 31/57] feat(close-gate): MCP task_close returns completeness_gaps --- crates/tj-mcp/src/main.rs | 67 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 96fc04d..32499fb 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -245,6 +245,11 @@ pub struct TaskCloseResult { /// to flag. #[serde(skip_serializing_if = "Option::is_none")] pub note: Option, + /// Completeness gaps surfaced at close time (from `completeness::assess`). + /// Non-blocking advisory — the close always succeeds. Empty when the task + /// has no detected gaps; omitted from the wire shape in that case. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub completeness_gaps: Vec, } fn parse_event_type(s: &str) -> anyhow::Result { @@ -547,7 +552,7 @@ impl TaskJournalServer { ) -> Result, McpError> { traced_tool("task_close", async move { let task_id = p.task_id.clone(); - let open_kids = run_blocking(move || { + let (open_kids, gaps) = run_blocking(move || { let (project_hash, events_path, state_path) = project_paths()?; let conn_arc = cached_open(&state_path)?; @@ -604,7 +609,23 @@ impl TaskJournalServer { let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; writer.flush_durable()?; - Ok(open_kids) + + // Non-blocking completeness check. The close above already + // succeeded; re-open, apply the close event to the index, then + // assess. Any error here must NOT fail the close — handle + // locally, never `?`-propagate. + let mut gaps: Vec = Vec::new(); + if let Ok(conn) = tj_core::db::open(&state_path) { + let _ = tj_core::db::ingest_new_events(&conn, &events_path, &project_hash); + if let Ok(report) = tj_core::completeness::assess( + &conn, + &p.task_id, + tj_core::completeness::pending_count(), + ) { + gaps = report.gaps.into_iter().map(|g| g.detail).collect(); + } + } + Ok((open_kids, gaps)) }) .await?; let note = if open_kids > 0 { @@ -616,6 +637,7 @@ impl TaskJournalServer { task_id, closed: true, note, + completeness_gaps: gaps, })) }) .await @@ -768,6 +790,7 @@ mod tests { task_id: "tj-x".into(), closed: true, note: None, + completeness_gaps: Vec::new(), }; assert!(!keys_of(&serde_json::to_value(&close).unwrap()).contains(&"stub".to_string())); } @@ -992,6 +1015,46 @@ mod tests { assert_eq!(res.note.as_deref(), Some("note: 1 open subtask(s)")); } + #[tokio::test] + async fn task_close_reports_completeness_gaps() { + let _env = handler_env(); + let server = TaskJournalServer; + + // Create a task WITH a goal so NoGoal won't fire. + let task = server + .task_create(Parameters(TaskCreateParams { + title: "Gap me".into(), + initial_context: None, + goal: Some("ship it".into()), + parent: None, + })) + .await + .unwrap() + .0 + .task_id; + + // Close WITHOUT an outcome → ClosedNoOutcome gap. + let res = server + .task_close(Parameters(TaskCloseParams { + task_id: task.clone(), + reason: "done".into(), + outcome: None, + outcome_tag: None, + })) + .await + .unwrap() + .0; + + assert!(res.closed); + assert!( + res.completeness_gaps + .iter() + .any(|g| g.contains("closed without a recorded outcome")), + "gaps: {:?}", + res.completeness_gaps + ); + } + #[test] fn into_mcp_error_carries_full_anyhow_chain() { // Down-stream callers rely on McpError.message containing the full From 70cf92a5f526b7433371f998a2eb8aa0b9bbe3dc Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:11:01 +0400 Subject: [PATCH 32/57] chore(close-gate): CHANGELOG entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73af91b..d025406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.0] ### Added +- Close gate: closing a task now surfaces its completeness gaps (from + `completeness::assess`) — CLI prints them to stderr, MCP `task_close` returns + them in a new `completeness_gaps` field. Non-blocking: the close always + succeeds. - Capture-completeness flagging: a task's resume-pack now shows a `Completeness` section listing structural gaps (closed without outcome, decisions without evidence, unconfirmed suggested events, missing goal, unclassified pending From 055bf9f78aaf7b75db3514d063e9806eeba2884b Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:14:54 +0400 Subject: [PATCH 33/57] feat(recall): pure relevant_recall engine for proactive recall Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/lib.rs | 1 + crates/tj-core/src/recall.rs | 318 +++++++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 crates/tj-core/src/recall.rs diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 77a1c2f..0bbca1c 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -57,6 +57,7 @@ pub mod fts; pub mod pack; pub mod paths; pub mod project_hash; +pub mod recall; pub mod session; pub mod session_id; pub mod storage; diff --git a/crates/tj-core/src/recall.rs b/crates/tj-core/src/recall.rs new file mode 100644 index 0000000..12e0962 --- /dev/null +++ b/crates/tj-core/src/recall.rs @@ -0,0 +1,318 @@ +//! Read-only proactive-recall engine. Given the current tool context, +//! return the most relevant prior confirmed `rejection`/`decision` events +//! so the agent doesn't re-walk a ruled-out path. Shared by the PostToolUse +//! push path (claude-memory-60m) and the MCP-output push path (7km). + +use crate::event::EventType; + +/// One recalled high-signal event that matched the current context. +#[derive(Debug, Clone, PartialEq)] +pub struct RecallHit { + pub task_id: String, + pub event_type: EventType, // Rejection | Decision + pub text: String, + pub score: f64, +} + +/// Max hits surfaced per call. Autonomously-chosen default, flagged for review. +pub const DEFAULT_MAX_HITS: usize = 2; +/// Min blended score to surface. Autonomously-chosen default, flagged for review. +pub const RELEVANCE_THRESHOLD: f64 = 1.0; + +/// FTS-safety check mirrors main.rs::topic_is_fts_safe. +fn fts_safe(q: &str) -> bool { + !q.chars() + .any(|c| matches!(c, '-' | '"' | '*' | ':' | '(' | ')')) +} + +/// Build an FTS5 OR-of-tokens query from a free-text context string. A raw +/// multi-word context like "let's switch to axum" parses as an implicit AND +/// under FTS5, so it would never match a short rejection that only shares one +/// token. We instead OR the individual word tokens (apostrophes/punctuation +/// stripped, stop-short tokens dropped) so any shared keyword scores a hit — +/// the same recall intent as `run_rejected`'s MATCH, widened for prose input. +/// Returns `None` when no usable token survives (caller skips the FTS branch). +fn fts_or_query(query_text: &str) -> Option { + let tokens: Vec = query_text + .split(|c: char| !c.is_alphanumeric()) + .filter(|t| t.chars().count() >= 3) + .map(|t| t.to_lowercase()) + .collect(); + if tokens.is_empty() { + return None; + } + Some(tokens.join(" OR ")) +} + +/// Search confirmed `rejection`/`decision` events for ones relevant to the +/// current context, blending an FTS5/LIKE text signal with artifact overlap. +/// +/// Read-only: never mutates the JSONL log or any derived table. Returns at +/// most `max_hits` hits scoring >= [`RELEVANCE_THRESHOLD`], sorted by score +/// descending with `Rejection` winning ties over `Decision`. +pub fn relevant_recall( + conn: &rusqlite::Connection, + query_text: &str, + max_hits: usize, +) -> anyhow::Result> { + use std::collections::HashMap; + if query_text.trim().is_empty() { + return Ok(Vec::new()); + } + + // score keyed by event_id; carry (task_id, type, text) for output. + let mut scores: HashMap = HashMap::new(); + let mut meta: HashMap = HashMap::new(); + + // 1) Text signal: FTS5 OR-of-tokens MATCH, restricted to confirmed + // rejection/decision (mirrors run_rejected's join). Falls back to a + // raw LIKE substring when the context isn't FTS-safe (e.g. it carries + // a hyphenated id like OPS-306) or yields no usable token — the same + // fallback shape run_search uses. + let use_fts = fts_safe(query_text) && fts_or_query(query_text).is_some(); + let sql = if use_fts { + "SELECT ei.event_id, ei.task_id, ei.type, sf.text + FROM events_index ei + JOIN search_fts sf ON sf.event_id = ei.event_id + WHERE ei.status = 'confirmed' + AND ei.type IN ('rejection','decision') + AND search_fts MATCH ?1" + } else { + "SELECT ei.event_id, ei.task_id, ei.type, sf.text + FROM events_index ei + JOIN search_fts sf ON sf.event_id = ei.event_id + WHERE ei.status = 'confirmed' + AND ei.type IN ('rejection','decision') + AND sf.text LIKE ?1" + }; + let bind = if use_fts { + fts_or_query(query_text).unwrap_or_default() + } else { + crate::fts::like_pattern(query_text) + }; + if let Ok(mut stmt) = conn.prepare(sql) { + let rows = stmt.query_map(rusqlite::params![bind], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + )) + }); + if let Ok(rows) = rows { + for row in rows.flatten() { + let (eid, tid, ty, text) = row; + let et = parse_type(&ty); + *scores.entry(eid.clone()).or_insert(0.0) += 1.0; // text-match weight + meta.entry(eid).or_insert((tid, et, text)); + } + } + } + + // 2) Artifact signal: overlap of artifacts::extract(query_text) against + // events_index.artifacts (mirrors find_related_tasks LIKE scan), same + // confirmed rejection/decision restriction. +weight per shared artifact. + let arts = crate::artifacts::extract(query_text); + for needle in arts + .linked_issues + .iter() + .chain(arts.commit_hashes.iter()) + .chain(arts.files.iter()) + { + let pattern = format!("%\"{}\"%", needle.replace('%', "\\%")); + if let Ok(mut stmt) = conn.prepare( + "SELECT ei.event_id, ei.task_id, ei.type, sf.text + FROM events_index ei + JOIN search_fts sf ON sf.event_id = ei.event_id + WHERE ei.status = 'confirmed' + AND ei.type IN ('rejection','decision') + AND ei.artifacts LIKE ?1", + ) { + let rows = stmt.query_map(rusqlite::params![pattern], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + )) + }); + if let Ok(rows) = rows { + for row in rows.flatten() { + let (eid, tid, ty, text) = row; + let et = parse_type(&ty); + *scores.entry(eid.clone()).or_insert(0.0) += 0.5; // artifact weight + meta.entry(eid).or_insert((tid, et, text)); + } + } + } + } + + // 3) Threshold + rank. Sort by score desc; tie → Rejection before Decision. + let mut hits: Vec = scores + .into_iter() + .filter(|(_, s)| *s >= RELEVANCE_THRESHOLD) + .filter_map(|(eid, score)| { + meta.remove(&eid).map(|(task_id, event_type, text)| RecallHit { + task_id, + event_type, + text, + score, + }) + }) + .collect(); + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| rank(a.event_type).cmp(&rank(b.event_type))) + }); + hits.truncate(max_hits); + Ok(hits) +} + +fn parse_type(s: &str) -> EventType { + match s { + "rejection" => EventType::Rejection, + _ => EventType::Decision, + } +} + +// Rejection ranks before Decision on a tie. +fn rank(t: EventType) -> u8 { + match t { + EventType::Rejection => 0, + _ => 1, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::event::{Author, Event, EventStatus, EventType, Source}; + + // Open a temp db and ingest a slice of events through the same + // `index_event` path db.rs tests use (db::open + index_event). + fn seeded(events: &[Event]) -> (tempfile::TempDir, rusqlite::Connection) { + let d = tempfile::TempDir::new().unwrap(); + let conn = db::open(d.path().join("s.sqlite")).unwrap(); + for e in events { + db::index_event(&conn, e).unwrap(); + } + (d, conn) + } + + fn ev(task: &str, ty: EventType, text: &str, status: EventStatus) -> Event { + let mut e = Event::new(task, ty, Author::Agent, Source::Chat, text.into()); + e.status = status; + e + } + + #[test] + fn returns_matching_confirmed_rejection() { + let rej = ev( + "tj-1", + EventType::Rejection, + "Tried switching the server to axum but it broke rmcp stdio.", + EventStatus::Confirmed, + ); + let (_d, conn) = seeded(&[rej]); + + let hits = relevant_recall(&conn, "let's switch to axum", DEFAULT_MAX_HITS).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].event_type, EventType::Rejection); + assert!(hits[0].text.contains("axum")); + } + + #[test] + fn ignores_suggested_and_wrong_type() { + let suggested = ev( + "tj-1", + EventType::Rejection, + "Rejected the axum migration tentatively.", + EventStatus::Suggested, + ); + let finding = ev( + "tj-1", + EventType::Finding, + "The axum server starts fine in isolation.", + EventStatus::Confirmed, + ); + let (_d, conn) = seeded(&[suggested, finding]); + + let hits = relevant_recall(&conn, "axum", DEFAULT_MAX_HITS).unwrap(); + assert!(hits.is_empty(), "got: {hits:?}"); + } + + #[test] + fn caps_at_max_hits() { + let events: Vec = (0..5) + .map(|i| { + ev( + "tj-1", + EventType::Rejection, + &format!("Rejected widget approach number {i} for the dashboard"), + EventStatus::Confirmed, + ) + }) + .collect(); + let (_d, conn) = seeded(&events); + + let hits = relevant_recall(&conn, "dashboard widget", 2).unwrap(); + assert_eq!(hits.len(), 2); + } + + #[test] + fn rejection_wins_tie_over_decision() { + let decision = ev( + "tj-1", + EventType::Decision, + "Decided to use the postgres connector.", + EventStatus::Confirmed, + ); + let rejection = ev( + "tj-2", + EventType::Rejection, + "Rejected the postgres connector for latency.", + EventStatus::Confirmed, + ); + let (_d, conn) = seeded(&[decision, rejection]); + + let hits = relevant_recall(&conn, "postgres connector", DEFAULT_MAX_HITS).unwrap(); + assert_eq!(hits.len(), 2); + // Same text-match score (1.0 each) → rejection ranks first. + assert_eq!(hits[0].event_type, EventType::Rejection); + assert_eq!(hits[1].event_type, EventType::Decision); + } + + #[test] + fn below_threshold_returns_empty() { + // No textual or artifact overlap → score stays 0 < threshold. + let rej = ev( + "tj-1", + EventType::Rejection, + "Rejected the kafka pipeline for cost reasons.", + EventStatus::Confirmed, + ); + let (_d, conn) = seeded(&[rej]); + + let hits = relevant_recall(&conn, "frontend styling refactor", DEFAULT_MAX_HITS).unwrap(); + assert!(hits.is_empty(), "got: {hits:?}"); + } + + #[test] + fn empty_query_returns_empty() { + let rej = ev( + "tj-1", + EventType::Rejection, + "Rejected axum.", + EventStatus::Confirmed, + ); + let (_d, conn) = seeded(&[rej]); + + assert!(relevant_recall(&conn, "", DEFAULT_MAX_HITS).unwrap().is_empty()); + assert!(relevant_recall(&conn, " ", DEFAULT_MAX_HITS) + .unwrap() + .is_empty()); + } +} From 85bf17e4379f088fec8a766d6b42a5111ac1acc4 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:19:08 +0400 Subject: [PATCH 34/57] feat(recall): PostToolUse hook surfaces prior rejections via additionalContext Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 55 +++++++++ crates/tj-cli/tests/cli.rs | 226 +++++++++++++++++++++++++++++++++++ crates/tj-core/src/recall.rs | 42 ++++--- 3 files changed, 307 insertions(+), 16 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 428971c..6c988d8 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1615,6 +1615,61 @@ fn main() -> Result<()> { // neither source is present (standalone behaviour unchanged). let live_session_id = tj_core::session_id::live_session_id(Some(&payload)); + // Push-recall (claude-memory-60m). Best-effort, fail-open, read-only. + // After a (non-MCP) tool call, surface a relevant prior + // rejection/decision via an additionalContext envelope so the agent + // doesn't re-walk a ruled-out path. Gated by TJ_PUSH_RECALL=0. + // + // Dedup vs claude-memory-7km: skip MCP-tool turns — those are + // handled by 7km's updatedMCPToolOutput path, so emitting + // additionalContext here too would double-surface the same recall. + // The two paths are mutually exclusive by tool type (this = + // non-mcp tools; 7km = mcp__ tools). The block only adds a stdout + // envelope; it never touches the JSONL log or the pending flow + // below, and any error is swallowed so the hook can't break. + let tool_is_mcp = payload + .get("tool_name") + .and_then(|v| v.as_str()) + .map(|n| n.starts_with("mcp__")) + .unwrap_or(false); + if kind == "PostToolUse" + && !tool_is_mcp + && std::env::var("TJ_PUSH_RECALL").as_deref() != Ok("0") + && events_path.exists() + { + let state_path = + tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite")); + if let Ok(conn) = tj_core::db::open(&state_path) { + let _ = tj_core::db::ingest_new_events(&conn, &events_path, &project_hash); + if let Ok(hits) = tj_core::recall::relevant_recall( + &conn, + &text, + tj_core::recall::DEFAULT_MAX_HITS, + ) { + if !hits.is_empty() { + let mut ctx = String::new(); + for h in &hits { + let verb = match h.event_type { + tj_core::event::EventType::Rejection => "previously rejected", + _ => "previously decided", + }; + ctx.push_str(&format!( + "⚠ recall: in task {} you {}: {}\n", + h.task_id, verb, h.text + )); + } + let envelope = serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ctx.trim_end(), + } + }); + println!("{}", serde_json::to_string(&envelope)?); + } + } + } + } + // SessionStart: emit a JSON envelope with compact resume-packs of // open tasks so Claude Code injects them into its system context // automatically. This is the load-bearing UX for "the journal diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index ecb7089..fe9926d 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -3923,3 +3923,229 @@ fn list_tree_indents_children_under_parents() { "child line must be indented, got: {child_line:?}" ); } + +// --- Push-recall (claude-memory-60m) ----------------------------------- + +/// Seed a confirmed rejection mentioning `axum` on a fresh task, returning +/// the (TempDir, task_id). Mirrors the SessionStart-test seeding: `create` +/// + `event --type rejection`. +fn seed_axum_rejection() -> (assert_fs::TempDir, String) { + 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", "Push recall host"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "event", + &task_id, + "--type", + "rejection", + "--text", + "Tried switching the server to axum but it broke rmcp stdio.", + ]) + .assert() + .success(); + (dir, task_id) +} + +#[test] +fn post_tool_use_emits_recall_additional_context() { + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "Bash", + "tool_input": { "command": "let's switch the server to axum" }, + "tool_response": { "output": "" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + + // The recall envelope is one of possibly several stdout lines (the + // mock path also prints the new event_id). Find the JSON line. + let env_line = body + .lines() + .find(|l| l.contains("additionalContext")) + .unwrap_or_else(|| panic!("no recall envelope on stdout, got: {body:?}")); + let v: serde_json::Value = serde_json::from_str(env_line.trim()).unwrap(); + let ctx = v + .get("hookSpecificOutput") + .and_then(|h| h.get("additionalContext")) + .and_then(|s| s.as_str()) + .expect("additionalContext missing"); + assert!(ctx.contains("⚠ recall"), "ctx: {ctx}"); + assert!(ctx.contains("axum"), "ctx: {ctx}"); + assert_eq!( + v.get("hookSpecificOutput") + .and_then(|h| h.get("hookEventName")) + .and_then(|s| s.as_str()), + Some("PostToolUse"), + ); +} + +#[test] +fn post_tool_use_no_recall_when_no_match() { + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "Bash", + "tool_input": { "command": "update the frontend stylesheet colors" }, + "tool_response": { "output": "" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + assert!( + !body.contains("additionalContext"), + "must not emit recall envelope for a non-matching tool turn, got: {body:?}" + ); +} + +#[test] +fn tj_push_recall_env_disables() { + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "Bash", + "tool_input": { "command": "let's switch the server to axum" }, + "tool_response": { "output": "" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .env("TJ_PUSH_RECALL", "0") + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + assert!( + !body.contains("additionalContext"), + "TJ_PUSH_RECALL=0 must suppress the recall envelope, got: {body:?}" + ); +} + +#[test] +fn post_tool_use_skips_recall_for_mcp_tools() { + // Dedup gate vs claude-memory-7km: mcp__ tool turns are 7km's territory + // (updatedMCPToolOutput). This path must stay silent for them. + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "mcp__task-journal__task_search", + "tool_input": { "query": "let's switch the server to axum" }, + "tool_response": { "output": "" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + assert!( + !body.contains("additionalContext"), + "mcp__ tool turns must not emit a recall envelope (7km owns them), got: {body:?}" + ); +} diff --git a/crates/tj-core/src/recall.rs b/crates/tj-core/src/recall.rs index 12e0962..ac8d492 100644 --- a/crates/tj-core/src/recall.rs +++ b/crates/tj-core/src/recall.rs @@ -19,24 +19,31 @@ pub const DEFAULT_MAX_HITS: usize = 2; /// Min blended score to surface. Autonomously-chosen default, flagged for review. pub const RELEVANCE_THRESHOLD: f64 = 1.0; -/// FTS-safety check mirrors main.rs::topic_is_fts_safe. -fn fts_safe(q: &str) -> bool { - !q.chars() - .any(|c| matches!(c, '-' | '"' | '*' | ':' | '(' | ')')) -} +/// Common, low-signal words dropped from the OR-token query. Without this, +/// a shared stopword like "the" between an unrelated tool call and a prior +/// rejection scores a spurious hit. Kept deliberately small — just the +/// high-frequency glue words plus the noise tokens that leak in from the +/// synthesized tool-call JSON (`Bash: {"command": …}`). +const STOPWORDS: &[&str] = &[ + "the", "and", "for", "with", "you", "are", "was", "but", "not", "this", + "that", "from", "have", "has", "had", "will", "your", "our", "out", "let", + "lets", "command", "output", "input", "tool", "bash", "name", "response", +]; /// Build an FTS5 OR-of-tokens query from a free-text context string. A raw /// multi-word context like "let's switch to axum" parses as an implicit AND /// under FTS5, so it would never match a short rejection that only shares one -/// token. We instead OR the individual word tokens (apostrophes/punctuation -/// stripped, stop-short tokens dropped) so any shared keyword scores a hit — -/// the same recall intent as `run_rejected`'s MATCH, widened for prose input. -/// Returns `None` when no usable token survives (caller skips the FTS branch). +/// token. We instead OR the individual word tokens (punctuation stripped, +/// short tokens and stopwords dropped) so any shared *meaningful* keyword +/// scores a hit — the same recall intent as `run_rejected`'s MATCH, widened +/// for prose input. Returns `None` when no usable token survives (caller +/// then falls back to a raw LIKE on the query). fn fts_or_query(query_text: &str) -> Option { let tokens: Vec = query_text .split(|c: char| !c.is_alphanumeric()) .filter(|t| t.chars().count() >= 3) .map(|t| t.to_lowercase()) + .filter(|t| !STOPWORDS.contains(&t.as_str())) .collect(); if tokens.is_empty() { return None; @@ -65,11 +72,14 @@ pub fn relevant_recall( let mut meta: HashMap = HashMap::new(); // 1) Text signal: FTS5 OR-of-tokens MATCH, restricted to confirmed - // rejection/decision (mirrors run_rejected's join). Falls back to a - // raw LIKE substring when the context isn't FTS-safe (e.g. it carries - // a hyphenated id like OPS-306) or yields no usable token — the same - // fallback shape run_search uses. - let use_fts = fts_safe(query_text) && fts_or_query(query_text).is_some(); + // rejection/decision (mirrors run_rejected's join). The tokenizer + // strips all punctuation, so the resulting `a OR b OR c` query is + // always FTS-safe even when the raw context is noisy tool-call JSON + // (`Bash: {"command":"…"}`) full of `:`/`"`/`{}` that would otherwise + // trip the FTS5 parser. Falls back to a raw LIKE substring only when + // no usable token survives — the same fallback shape run_search uses. + let fts_or = fts_or_query(query_text); + let use_fts = fts_or.is_some(); let sql = if use_fts { "SELECT ei.event_id, ei.task_id, ei.type, sf.text FROM events_index ei @@ -85,8 +95,8 @@ pub fn relevant_recall( AND ei.type IN ('rejection','decision') AND sf.text LIKE ?1" }; - let bind = if use_fts { - fts_or_query(query_text).unwrap_or_default() + let bind = if let Some(or_query) = fts_or { + or_query } else { crate::fts::like_pattern(query_text) }; From 7648fe60085a928f802f1638080a85f52a767153 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:19:51 +0400 Subject: [PATCH 35/57] chore(recall): CHANGELOG entry for push-recall Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d025406..4fea0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.0] ### Added +- Push-recall: after a tool call, the PostToolUse hook surfaces a relevant prior + `rejection`/`decision` via `additionalContext` ("⚠ recall: in task X you + previously rejected …"), so the agent doesn't re-walk a ruled-out path. + Conservative (≤2 hits above a relevance threshold), read-only, best-effort + (errors never break the hook). New `tj_core::recall::relevant_recall` engine. + Disable with `TJ_PUSH_RECALL=0`. - Close gate: closing a task now surfaces its completeness gaps (from `completeness::assess`) — CLI prints them to stderr, MCP `task_close` returns them in a new `completeness_gaps` field. Non-blocking: the close always From 7908f085b0e33d8e3f7ef31f8f5db0cf72fab799 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:23:35 +0400 Subject: [PATCH 36/57] feat(push-recall): MCP PostToolUse prepends recall banner via updatedMCPToolOutput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements claude-memory-60m's additionalContext path (which skips mcp__ tools). Gated MCP-only; falls through to the capture queue so event capture is unaffected. Best-effort: no hit / any error passes the output through unchanged. Reuses tj_core::recall::relevant_recall — no recall logic here. Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 95 +++++++++++++++++ crates/tj-cli/tests/cli.rs | 209 +++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 6c988d8..7b8302c 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1670,6 +1670,21 @@ fn main() -> Result<()> { } } + // Push-recall via updatedMCPToolOutput (claude-memory-7km). For an + // MCP PostToolUse turn whose input echoes a prior rejection/decision, + // prepend a recall banner to what Claude sees of that tool's output. + // Best-effort, read-only: any miss or error emits nothing and the + // real output passes through unchanged. Complements 60m (which skips + // mcp__ tools) — gated MCP-only, falls through to the queue path so + // event capture is unaffected. Disabled by TJ_PUSH_RECALL=0. + if kind == "PostToolUse" && std::env::var("TJ_PUSH_RECALL").as_deref() != Ok("0") { + if let Some(envelope) = + push_recall_envelope(&payload, &events_path, &project_hash) + { + println!("{}", serde_json::to_string(&envelope)?); + } + } + // SessionStart: emit a JSON envelope with compact resume-packs of // open tasks so Claude Code injects them into its system context // automatically. This is the load-bearing UX for "the journal @@ -3857,6 +3872,86 @@ fn drain_pending( /// piping), we silently return ("Stop", "") so the hook becomes a no-op /// instead of erroring — matches the `|| true` safety net in the /// installed hook command. +/// Build a PostToolUse `updatedMCPToolOutput` envelope when an MCP tool call +/// echoes a prior rejection/decision (claude-memory-7km). Returns None +/// (pass through, emit nothing) for non-MCP tools, no hits, or any error. +/// Never panics, never mutates the journal. +/// +/// Dedup vs claude-memory-60m: this fires ONLY for `mcp__` tools; 60m's +/// `additionalContext` path skips those. The two are mutually exclusive by +/// tool type so a single recall is never double-surfaced. +fn push_recall_envelope( + payload: &serde_json::Value, + events_path: &std::path::Path, + project_hash: &str, +) -> Option { + // MCP-only gate: Claude Code prefixes MCP tools `mcp____`. + let tool_name = payload.get("tool_name").and_then(|v| v.as_str())?; + if !tool_name.starts_with("mcp__") { + return None; + } + if !events_path.exists() { + return None; + } + let query_text = payload + .get("tool_input") + .map(|v| v.to_string()) + .unwrap_or_default(); + if query_text.trim().is_empty() { + return None; + } + let original = payload + .get("tool_response") + .map(render_tool_response) + .unwrap_or_default(); + + let state_path = tj_core::paths::state_dir() + .ok()? + .join(format!("{project_hash}.sqlite")); + let conn = tj_core::db::open(&state_path).ok()?; + let _ = tj_core::db::ingest_new_events(&conn, events_path, project_hash); + // Reuse 60m's recall engine + threshold — no recall logic lives here. + let hits = + tj_core::recall::relevant_recall(&conn, &query_text, tj_core::recall::DEFAULT_MAX_HITS) + .ok()?; + if hits.is_empty() { + return None; + } + let updated = format!("{}\n\n{}", render_recall_banner(&hits), original); + Some(serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "updatedMCPToolOutput": updated, + } + })) +} + +/// One ⚠ line per recall hit (mirrors the close-gate / SessionStart convention). +fn render_recall_banner(hits: &[tj_core::recall::RecallHit]) -> String { + let mut s = String::from("\u{26a0} Task Journal recall — you may be repeating a prior path:"); + for h in hits { + let verb = match h.event_type { + tj_core::event::EventType::Rejection => "rejected", + _ => "decided on", + }; + s.push_str(&format!( + "\n \u{26a0} in task {} you previously {} this: {}", + h.task_id, verb, h.text + )); + } + s +} + +/// Collapse a `tool_response` JSON value to the text Claude would have seen. +/// A bare string is used as-is; any other JSON is stringified (mirrors how +/// `parse_hook_stdin` stringifies `tool_response`). +fn render_tool_response(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + fn parse_hook_stdin() -> anyhow::Result<(String, String, serde_json::Value)> { let mut buf = String::new(); std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf) diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index fe9926d..83c2c3b 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -4149,3 +4149,212 @@ fn post_tool_use_skips_recall_for_mcp_tools() { "mcp__ tool turns must not emit a recall envelope (7km owns them), got: {body:?}" ); } + +// --- Push-recall via updatedMCPToolOutput (claude-memory-7km) ---------- + +#[test] +fn post_tool_use_mcp_prepends_recall_banner() { + // An MCP tool call whose input echoes a prior rejection must have a recall + // banner PREPENDED to what Claude sees of its output, with the real output + // preserved below the banner (7km's updatedMCPToolOutput path). + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "mcp__some-server__do_thing", + "tool_input": { "approach": "let's switch the server to axum" }, + "tool_response": { "output": "REAL TOOL OUTPUT 12345" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + + let env_line = body + .lines() + .find(|l| l.contains("updatedMCPToolOutput")) + .unwrap_or_else(|| panic!("no updatedMCPToolOutput envelope on stdout, got: {body:?}")); + let v: serde_json::Value = serde_json::from_str(env_line.trim()).unwrap(); + let updated = v + .get("hookSpecificOutput") + .and_then(|h| h.get("updatedMCPToolOutput")) + .and_then(|s| s.as_str()) + .expect("updatedMCPToolOutput missing"); + assert!(updated.starts_with('\u{26a0}'), "must start with banner: {updated}"); + assert!(updated.contains("axum"), "banner must mention the recall: {updated}"); + assert!( + updated.contains("REAL TOOL OUTPUT 12345"), + "original tool output must be preserved: {updated}" + ); + assert_eq!( + v.get("hookSpecificOutput") + .and_then(|h| h.get("hookEventName")) + .and_then(|s| s.as_str()), + Some("PostToolUse"), + ); +} + +#[test] +fn post_tool_use_non_mcp_tool_emits_no_mcp_output() { + // The 7km path is MCP-only: a non-MCP tool (Bash) must never emit + // updatedMCPToolOutput, even when its input echoes a rejection. + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "Bash", + "tool_input": { "command": "let's switch the server to axum" }, + "tool_response": { "output": "REAL TOOL OUTPUT 12345" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + assert!( + !body.contains("updatedMCPToolOutput"), + "non-MCP tool turns must not emit updatedMCPToolOutput, got: {body:?}" + ); +} + +#[test] +fn post_tool_use_mcp_no_recall_passes_through() { + // MCP tool, but its input matches no rejection: emit nothing, pass through. + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "mcp__some-server__do_thing", + "tool_input": { "approach": "update the frontend stylesheet colors" }, + "tool_response": { "output": "REAL TOOL OUTPUT 12345" } + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + assert!( + !body.contains("updatedMCPToolOutput"), + "no-hit MCP turn must pass through (no envelope), got: {body:?}" + ); +} + +#[test] +fn push_recall_mcp_does_not_drop_capture() { + // The push-recall block must fall through to the normal capture path: + // the MCP tool call is still ingested as an event (mock path emits the + // new event_id on stdout). + let (dir, task_id) = seed_axum_rejection(); + + let payload = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "s-recall", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "tool_name": "mcp__some-server__do_thing", + "tool_input": { "approach": "let's switch the server to axum" }, + "tool_response": { "output": "REAL TOOL OUTPUT 12345" } + }) + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "ingest-hook", + "--backend", + "cli", + "--mock-event-type", + "finding", + "--mock-task-id", + &task_id, + "--mock-confidence", + "0.9", + ]) + .write_stdin(payload) + .assert() + .success(); + + // The captured finding lands in the task pack — capture is unaffected by + // the push-recall envelope. + let pack = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap(); + assert!( + pack.contains("do_thing"), + "MCP tool call must still be captured into the journal, pack: {pack}" + ); +} From b63161cc18be00a3c83a74a7db71ecf584eb567e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:24:08 +0400 Subject: [PATCH 37/57] docs(push-recall): CHANGELOG entry for updatedMCPToolOutput recall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No version bump — workspace is already 0.12.0; sibling bullet under the existing push-recall (additionalContext) entry. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fea0ff..f424851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Conservative (≤2 hits above a relevance threshold), read-only, best-effort (errors never break the hook). New `tj_core::recall::relevant_recall` engine. Disable with `TJ_PUSH_RECALL=0`. +- Push-recall via `updatedMCPToolOutput`: when Claude calls an **MCP** tool whose + input echoes a prior rejection/decision (same `recall::relevant_recall` engine), + the PostToolUse hook prepends a `⚠` recall banner to what Claude sees of that + tool's output. MCP tools only — complements the `additionalContext` path above, + which skips `mcp__` tools, so a recall is never double-surfaced. The real output + is preserved below the banner; any miss or error passes it through unchanged. + Read-only, best-effort; also gated by `TJ_PUSH_RECALL=0`. - Close gate: closing a task now surfaces its completeness gaps (from `completeness::assess`) — CLI prints them to stderr, MCP `task_close` returns them in a new `completeness_gaps` field. Non-blocking: the close always From f6084de254a5d32630453c429ea1e04e780a6ae3 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:30:03 +0400 Subject: [PATCH 38/57] feat(core): migration v7 + project decision alternatives column Add nullable alternatives column to decisions table (migration v7) and project a decision event's meta.alternatives JSON into it on index. The append-only log is untouched; existing decisions stay NULL. Pack cache is wiped so packs re-render once events carry alternatives. Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/db.rs | 87 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 90d991f..b03ab7f 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -118,6 +118,16 @@ ALTER TABLE tasks ADD COLUMN parent_id TEXT; CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); "#; +/// v0.12.0 structured decision alternatives — nullable `alternatives` +/// carries the JSON array from a decision event's `meta.alternatives` +/// (objects like `{option, chosen, rationale}`). Existing decisions stay +/// NULL; the append-only log is untouched. Wipes the pack cache so packs +/// re-render with the alternatives block once events carry it. +const MIGRATION_007: &str = r#" +ALTER TABLE decisions ADD COLUMN alternatives TEXT; +DELETE FROM task_pack_cache; +"#; + /// All schema migrations in version order. Append new entries here; never /// edit a published migration's `sql` — write a new one instead. const MIGRATIONS: &[Migration] = &[ @@ -145,6 +155,10 @@ const MIGRATIONS: &[Migration] = &[ version: 6, sql: MIGRATION_006, }, + Migration { + version: 7, + sql: MIGRATION_007, + }, ]; fn apply_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -807,10 +821,17 @@ pub fn index_event(conn: &Connection, event: &Event) -> anyhow::Result<()> { )?; if event.event_type == EventType::Decision { + // v0.12.0: project structured alternatives (meta.alternatives) into + // a dedicated column so pack can render "considered A/B/C, chose X". + // Stored as the verbatim JSON of the meta value; NULL when absent. + let alternatives_json = match event.meta.get("alternatives") { + Some(v) if !v.is_null() => Some(serde_json::to_string(v)?), + _ => None, + }; conn.execute( - "INSERT OR REPLACE INTO decisions(decision_id, task_id, text, status) - VALUES (?1, ?2, ?3, 'active')", - rusqlite::params![event.event_id, event.task_id, event.text], + "INSERT OR REPLACE INTO decisions(decision_id, task_id, text, status, alternatives) + VALUES (?1, ?2, ?3, 'active', ?4)", + rusqlite::params![event.event_id, event.task_id, event.text, alternatives_json], )?; } @@ -1234,6 +1255,66 @@ mod tests { assert_eq!(status, "active"); } + #[test] + fn index_event_projects_decision_alternatives_into_column() { + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + + let mut dec = crate::event::Event::new( + "tj-alt", + crate::event::EventType::Decision, + crate::event::Author::Agent, + crate::event::Source::Chat, + "Use SQLite".into(), + ); + dec.meta = serde_json::json!({ + "alternatives": [ + {"option": "SQLite", "chosen": true, "rationale": "embedded, zero-ops"}, + {"option": "Postgres", "chosen": false, "rationale": "too heavy for local tool"} + ] + }); + upsert_task_from_event(&conn, &dec, "feedface").unwrap(); + index_event(&conn, &dec).unwrap(); + + let alts: Option = conn + .query_row( + "SELECT alternatives FROM decisions WHERE decision_id=?1", + rusqlite::params![dec.event_id], + |r| r.get(0), + ) + .unwrap(); + let alts = alts.expect("alternatives column should be populated"); + let parsed: serde_json::Value = serde_json::from_str(&alts).unwrap(); + assert_eq!(parsed.as_array().unwrap().len(), 2); + assert_eq!(parsed[0]["option"], "SQLite"); + assert_eq!(parsed[0]["chosen"], true); + } + + #[test] + fn index_event_decision_without_alternatives_leaves_column_null() { + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + + let dec = crate::event::Event::new( + "tj-noalt", + crate::event::EventType::Decision, + crate::event::Author::Agent, + crate::event::Source::Chat, + "Plain decision".into(), + ); + upsert_task_from_event(&conn, &dec, "feedface").unwrap(); + index_event(&conn, &dec).unwrap(); + + let alts: Option = conn + .query_row( + "SELECT alternatives FROM decisions WHERE decision_id=?1", + rusqlite::params![dec.event_id], + |r| r.get(0), + ) + .unwrap(); + assert!(alts.is_none()); + } + #[test] fn index_event_is_idempotent_no_search_fts_duplicates() { let d = TempDir::new().unwrap(); From 193667383066b5b256f52b9634f4e1f7aecf38d4 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:31:09 +0400 Subject: [PATCH 39/57] feat(pack): render structured decision alternatives render_active_decisions now reads the alternatives column and renders a 'considered:' block under each decision, marking the chosen option and showing each rationale. Malformed/empty payloads are skipped so the decision itself always renders. Coexists with the Completeness and Subtasks sections added earlier. Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/pack.rs | 104 +++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 417db02..9e64bec 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -116,12 +116,21 @@ fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result(0))?; + let rows = stmt.query_map(rusqlite::params![task_id], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, Option>(1)?)) + })?; let mut count = 0; for row in rows { - out.push_str(&format!("- {}\n", row?)); + let (text, alternatives) = row?; + out.push_str(&format!("- {text}\n")); + // v0.12.0: structured alternatives render under the decision so the + // pack shows "considered A/B/C, chose X" without reconstructing it + // from the hypothesis+rejection chain. + if let Some(block) = render_alternatives(alternatives.as_deref()) { + out.push_str(&block); + } count += 1; } if count == 0 { @@ -131,6 +140,35 @@ fn render_active_decisions(conn: &Connection, task_id: &str) -> anyhow::Result) -> Option { + let raw = raw?; + let parsed: serde_json::Value = serde_json::from_str(raw).ok()?; + let arr = parsed.as_array()?; + if arr.is_empty() { + return None; + } + let mut block = String::from(" - considered:\n"); + for entry in arr { + let option = entry.get("option").and_then(|v| v.as_str())?; + let chosen = entry + .get("chosen") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let marker = if chosen { "✓ chose" } else { "✗" }; + let rationale = entry.get("rationale").and_then(|v| v.as_str()); + match rationale { + Some(r) => block.push_str(&format!(" - {marker} {option} — {r}\n")), + None => block.push_str(&format!(" - {marker} {option}\n")), + } + } + Some(block) +} + fn render_lifecycle(conn: &Connection, task_id: &str) -> anyhow::Result { let mut out = String::from("## Lifecycle\n"); let mut stmt = conn.prepare( @@ -958,6 +996,66 @@ mod tests { ); } + #[test] + fn pack_renders_decision_alternatives() { + use crate::db; + use crate::event::*; + use tempfile::TempDir; + + let d = TempDir::new().unwrap(); + let conn = db::open(d.path().join("s.sqlite")).unwrap(); + let mut open_e = Event::new( + "tj-alt", + EventType::Open, + Author::User, + Source::Cli, + "x".into(), + ); + open_e.meta = serde_json::json!({"title": "Alt test"}); + db::upsert_task_from_event(&conn, &open_e, "feedface").unwrap(); + db::index_event(&conn, &open_e).unwrap(); + + let mut dec = Event::new( + "tj-alt", + EventType::Decision, + Author::Agent, + Source::Chat, + "Use SQLite for storage".into(), + ); + dec.meta = serde_json::json!({ + "alternatives": [ + {"option": "SQLite", "chosen": true, "rationale": "embedded, zero-ops"}, + {"option": "Postgres", "chosen": false, "rationale": "too heavy for a local tool"} + ] + }); + db::upsert_task_from_event(&conn, &dec, "feedface").unwrap(); + db::index_event(&conn, &dec).unwrap(); + + let pack = assemble(&conn, "tj-alt", PackMode::Full).unwrap(); + // The decision itself still renders. + assert!( + pack.text.contains("Use SQLite for storage"), + "decision text missing: {}", + pack.text + ); + // Considered alternatives surface, both chosen and rejected, with rationale. + assert!( + pack.text.contains("considered"), + "alternatives header missing: {}", + pack.text + ); + assert!( + pack.text.contains("SQLite") && pack.text.contains("Postgres"), + "both options missing: {}", + pack.text + ); + assert!( + pack.text.contains("too heavy for a local tool"), + "rejected rationale missing: {}", + pack.text + ); + } + #[test] fn assemble_includes_lifecycle_history() { use crate::db; From d51fe7ad00dcfdbcdfec01efddfcf8651bec1a53 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:32:59 +0400 Subject: [PATCH 40/57] feat(mcp): event_add alternatives param (decision-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventAddParams gains an optional alternatives JSON value. On a decision event it is stamped onto meta.alternatives; on any other event type the handler rejects it with a clear error. MCP-only for v1 — no CLI path. Co-Authored-By: Claude Opus 4.8 --- crates/tj-mcp/src/main.rs | 103 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 32499fb..c2239ed 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -219,6 +219,11 @@ pub struct EventAddParams { pub text: String, pub corrects: Option, pub supersedes: Option, + /// v0.12.0: structured alternatives for a `decision` event — a JSON + /// array of `{option, chosen, rationale}` objects making the considered + /// options and the final choice explicit. Stamped onto + /// `meta.alternatives`. Rejected with an error on any non-decision type. + pub alternatives: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct EventAddResult { @@ -512,6 +517,17 @@ impl TaskJournalServer { std::fs::create_dir_all(events_path.parent().unwrap())?; let event_type = parse_event_type(&p.event_type)?; + // v0.12.0: structured alternatives are decision-only. Reject + // them on any other type with a clear error rather than + // silently dropping the payload. + if p.alternatives.is_some() + && event_type != tj_core::event::EventType::Decision + { + anyhow::bail!( + "`alternatives` is only valid on a `decision` event (got `{}`)", + p.event_type + ); + } let mut event = tj_core::event::Event::new( &p.task_id, event_type, @@ -521,6 +537,11 @@ impl TaskJournalServer { ); event.corrects = p.corrects.clone(); event.supersedes = p.supersedes.clone(); + if let Some(alts) = &p.alternatives { + if let Some(obj) = event.meta.as_object_mut() { + obj.insert("alternatives".into(), alts.clone()); + } + } tj_core::session_id::stamp_session_id( &mut event.meta, tj_core::session_id::session_id_from_env().as_deref(), @@ -928,6 +949,88 @@ mod tests { assert!(cli.project_dir.is_none()); } + #[tokio::test] + async fn event_add_decision_stamps_alternatives_meta() { + let _env = handler_env(); + let server = TaskJournalServer; + + let task = server + .task_create(Parameters(TaskCreateParams { + title: "Alt task".into(), + initial_context: None, + goal: None, + parent: None, + })) + .await + .unwrap() + .0 + .task_id; + + let alts = serde_json::json!([ + {"option": "SQLite", "chosen": true, "rationale": "embedded"}, + {"option": "Postgres", "chosen": false, "rationale": "too heavy"} + ]); + let res = server + .event_add(Parameters(EventAddParams { + task_id: task.clone(), + event_type: "decision".into(), + text: "Use SQLite".into(), + corrects: None, + supersedes: None, + alternatives: Some(alts.clone()), + })) + .await + .unwrap() + .0; + + let (_, events_path, _) = project_paths().unwrap(); + let jsonl = std::fs::read_to_string(&events_path).unwrap(); + let ev = jsonl + .lines() + .map(|l| serde_json::from_str::(l).unwrap()) + .find(|v| v.get("event_id").and_then(|x| x.as_str()) == Some(res.event_id.as_str())) + .expect("decision event in jsonl"); + assert_eq!(ev["meta"]["alternatives"], alts); + } + + #[tokio::test] + async fn event_add_rejects_alternatives_on_non_decision() { + let _env = handler_env(); + let server = TaskJournalServer; + + let task = server + .task_create(Parameters(TaskCreateParams { + title: "Reject task".into(), + initial_context: None, + goal: None, + parent: None, + })) + .await + .unwrap() + .0 + .task_id; + + let res = server + .event_add(Parameters(EventAddParams { + task_id: task, + event_type: "finding".into(), + text: "some finding".into(), + corrects: None, + supersedes: None, + alternatives: Some(serde_json::json!([{"option": "x", "chosen": true}])), + })) + .await; + let err = match res { + Ok(_) => panic!("alternatives on a finding must be rejected"), + Err(e) => e, + }; + let msg = format!("{err}"); + assert!( + msg.contains("alternatives") && msg.contains("decision"), + "error should explain alternatives is decision-only: {msg}" + ); + } + #[tokio::test] async fn task_create_with_parent_stamps_meta() { // Isolate state under a temp XDG home and a unique project dir From 64171129eacd2b3d7307f9967c12ab92805cc03d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:33:40 +0400 Subject: [PATCH 41/57] docs(changelog): structured decision alternatives under 0.12.0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f424851..c959bdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 direct children (status, id, title). `list --tree` renders the hierarchy. Closing a parent with open children warns but proceeds. Additive — existing flat tasks are unaffected (`parent_id` NULL). +- Structured decision alternatives: a `decision` event can now carry + `meta.alternatives` — a JSON array of `{option, chosen, rationale}` making the + options considered and the final choice explicit instead of implicit in the + hypothesis+rejection chain. Set via the MCP `event_add` `alternatives` param + (decision-only — rejected with an error on any other event type). Projected to + a new `decisions.alternatives` column (migration v7) and rendered under each + entry in the resume-pack's Active decisions section. MCP-only for v1; additive + — the JSONL source of truth is untouched and existing decisions stay NULL. ## [0.11.1] - 2026-06-08 From 36c9988e1cd7e6a6ee8c61c756f0e030748c42e1 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:38:14 +0400 Subject: [PATCH 42/57] feat(constraint-context): add TaskContext.constraints field (additive) Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 2 ++ crates/tj-core/src/classifier/heuristic.rs | 1 + crates/tj-core/src/classifier/hybrid.rs | 1 + crates/tj-core/src/classifier/mod.rs | 14 ++++++++++++++ crates/tj-core/src/classifier/prompt.rs | 2 ++ 5 files changed, 20 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 7b8302c..1e5010e 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -3257,6 +3257,7 @@ fn recent_task_contexts( task_id, title, last_events, + constraints: vec![], }); } Ok(out) @@ -3335,6 +3336,7 @@ fn auto_open_task_from_prompt( task_id, title, last_events: vec![], + constraints: vec![], }) } diff --git a/crates/tj-core/src/classifier/heuristic.rs b/crates/tj-core/src/classifier/heuristic.rs index 902f12b..db99a70 100644 --- a/crates/tj-core/src/classifier/heuristic.rs +++ b/crates/tj-core/src/classifier/heuristic.rs @@ -187,6 +187,7 @@ mod tests { task_id: "tj-xyz".into(), title: "test".into(), last_events: vec![], + constraints: vec![], }], } } diff --git a/crates/tj-core/src/classifier/hybrid.rs b/crates/tj-core/src/classifier/hybrid.rs index 3fffa83..7d8fa00 100644 --- a/crates/tj-core/src/classifier/hybrid.rs +++ b/crates/tj-core/src/classifier/hybrid.rs @@ -84,6 +84,7 @@ mod tests { task_id: "tj-abc".into(), title: "test".into(), last_events: vec![], + constraints: vec![], }], } } diff --git a/crates/tj-core/src/classifier/mod.rs b/crates/tj-core/src/classifier/mod.rs index d82163c..72d9bcd 100644 --- a/crates/tj-core/src/classifier/mod.rs +++ b/crates/tj-core/src/classifier/mod.rs @@ -16,6 +16,9 @@ pub struct TaskContext { pub task_id: String, pub title: String, pub last_events: Vec, + /// The task's most-recent `constraint` events (≤ N). Empty when the + /// task has no constraints — the prompt is then unchanged. + pub constraints: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -97,6 +100,17 @@ mod tests { } } + #[test] + fn task_context_has_constraints_field() { + let c = TaskContext { + task_id: "tj-1".into(), + title: "t".into(), + last_events: vec![], + constraints: vec!["must support PHP 7.4".into()], + }; + assert_eq!(c.constraints, vec!["must support PHP 7.4".to_string()]); + } + #[test] fn classify_input_serializes() { let i = ClassifyInput { diff --git a/crates/tj-core/src/classifier/prompt.rs b/crates/tj-core/src/classifier/prompt.rs index f96c893..f892a13 100644 --- a/crates/tj-core/src/classifier/prompt.rs +++ b/crates/tj-core/src/classifier/prompt.rs @@ -97,6 +97,7 @@ mod tests { task_id: "tj-7f3a".into(), title: "OAuth login".into(), last_events: vec!["[hypothesis] PKCE vs implicit".into()], + constraints: vec![], }], }; let p = build(&input); @@ -118,6 +119,7 @@ mod tests { last_events: (0..30) .map(|j| format!("[finding] very long evidence text {i}/{j} ").repeat(20)) .collect(), + constraints: vec![], }) .collect(), }; From 80b8544f86d9d8042aa78e0b41f062070260d1e6 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:39:16 +0400 Subject: [PATCH 43/57] feat(constraint-context): render Known constraints block in classifier prompt Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/classifier/prompt.rs | 67 +++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/crates/tj-core/src/classifier/prompt.rs b/crates/tj-core/src/classifier/prompt.rs index f892a13..5f0b75e 100644 --- a/crates/tj-core/src/classifier/prompt.rs +++ b/crates/tj-core/src/classifier/prompt.rs @@ -17,21 +17,44 @@ pub fn build(input: &ClassifyInput) -> String { .take(3) .map(|s| s.chars().take(120).collect::()) .collect(); + let constraints_line = if t.constraints.is_empty() { + String::new() + } else { + let cs: Vec = t + .constraints + .iter() + .map(|s| s.chars().take(120).collect::()) + .collect(); + format!("\n Known constraints for {}: {}", t.task_id, cs.join("; ")) + }; format!( - "- {} \"{}\": {}", + "- {} \"{}\": {}{}", t.task_id, t.title, if trimmed_events.is_empty() { "(no events)".into() } else { trimmed_events.join("; ") - } + }, + constraints_line ) }) .collect::>() .join("\n") }; + // When any active task carries constraints, append a weigh-constraints + // instruction so the classifier prefers rejection/correction for chunks + // that violate them. Empty constraint sets expand to "" → prompt unchanged. + let any_constraints = input.recent_tasks.iter().any(|t| !t.constraints.is_empty()); + let constraint_rule = if any_constraints { + "\n - constraint awareness: When a chunk contradicts or violates a \ + listed constraint for its task, prefer rejection (ruled out by the \ + constraint) or correction (revises an earlier event that ignored it)." + } else { + "" + }; + format!( "You classify chat chunks for an AI-coding-agent task journal.\n\n\ EVENT TYPE DEFINITIONS (choose the most specific match):\n\ @@ -49,7 +72,7 @@ pub fn build(input: &ClassifyInput) -> String { IMPORTANT DISTINCTIONS:\n\ - hypothesis vs finding: hypothesis = \"I think\"/\"maybe\"/\"could be\"; finding = \"I see\"/\"the code shows\"/\"confirmed that\"\n\ - finding vs evidence: finding = discovered a fact; evidence = ran a test/experiment that PROVES something\n\ - - decision vs hypothesis: decision = committed choice; hypothesis = exploring an option\n\n\ + - decision vs hypothesis: decision = committed choice; hypothesis = exploring an option{constraint_rule}\n\n\ ## Examples\n\ The dashed lines separate Input (assistant or user chunk) from Output (the JSON you must produce). Use them as anchors for the boundary calls above.\n\n\ Input: \"I think the timeout is happening because the Anthropic SDK keeps the socket open after the read.\"\n\ @@ -79,7 +102,7 @@ pub fn build(input: &ClassifyInput) -> String { 5. A 1-2 sentence suggested_text capturing the essence. Be specific: include file names, function names, IDs when present.\n\n\ Respond ONLY with strict JSON, no commentary:\n\ {{\"event_type\":\"...\",\"task_id_guess\":\"...\"|null,\"confidence\":0.0,\"evidence_strength\":\"...\"|null,\"suggested_text\":\"...\"}}", - author=input.author_hint, text=input.text + author=input.author_hint, text=input.text, constraint_rule=constraint_rule ) } @@ -154,6 +177,42 @@ mod tests { ); } + #[test] + fn prompt_renders_constraints_block_when_present() { + let input = ClassifyInput { + text: "switch to async I/O".into(), + author_hint: "assistant".into(), + recent_tasks: vec![TaskContext { + task_id: "tj-9".into(), + title: "HTTP client".into(), + last_events: vec!["[decision] use ureq".into()], + constraints: vec!["must stay sync (no tokio)".into()], + }], + }; + let p = build(&input); + assert!(p.contains("Known constraints for tj-9:")); + assert!(p.contains("must stay sync (no tokio)")); + // the weigh-constraints instruction appears because a task has constraints + assert!(p.contains("contradicts or violates a listed constraint")); + } + + #[test] + fn prompt_unchanged_when_no_constraints() { + let input = ClassifyInput { + text: "x".into(), + author_hint: "user".into(), + recent_tasks: vec![TaskContext { + task_id: "tj-9".into(), + title: "t".into(), + last_events: vec!["[finding] y".into()], + constraints: vec![], + }], + }; + let p = build(&input); + assert!(!p.contains("Known constraints")); + assert!(!p.contains("contradicts or violates a listed constraint")); + } + #[test] fn prompt_handles_empty_tasks() { let input = ClassifyInput { From b5b3ffe1628ba1aa859d22123cb184822b7cc2b4 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:40:36 +0400 Subject: [PATCH 44/57] feat(constraint-context): gather task constraints into recent_task_contexts Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 133 +++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 1e5010e..dcb8724 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -3223,6 +3223,10 @@ fn run_export_pr(task_id: &str) -> Result<()> { Ok(()) } +/// How many of a task's most-recent `constraint` events to surface in +/// the classifier prompt. Kept small so the prompt stays bounded. +const CONSTRAINT_CONTEXT_LIMIT: i64 = 5; + fn recent_task_contexts( conn: &rusqlite::Connection, limit: usize, @@ -3253,11 +3257,35 @@ fn recent_task_contexts( )) })? .collect::>()?; + + // Gather the task's most-recent `constraint` events so the + // classifier can recognise chunks that violate a known limit. + // Mirrors the last_events join, filtered to constraints and + // bounded to keep the prompt small. + let mut c_stmt = conn.prepare( + "SELECT sf.text FROM events_index ei + LEFT JOIN search_fts sf ON sf.event_id = ei.event_id + WHERE ei.task_id = ?1 AND ei.type = 'constraint' + ORDER BY ei.timestamp DESC LIMIT ?2", + )?; + let constraints: Vec = c_stmt + .query_map( + rusqlite::params![task_id, CONSTRAINT_CONTEXT_LIMIT], + |r| { + let txt: Option = r.get(0)?; + Ok(txt.unwrap_or_default().chars().take(120).collect::()) + }, + )? + .collect::, _>>()? + .into_iter() + .filter(|s| !s.is_empty()) + .collect(); + out.push(tj_core::classifier::TaskContext { task_id, title, last_events, - constraints: vec![], + constraints, }); } Ok(out) @@ -4265,6 +4293,109 @@ mod inline_tests { assert!(!is_rewind_prompt("/rewinder")); } + #[test] + fn recent_task_contexts_gathers_constraints() { + use tj_core::event::{Author, Event, EventType, Source}; + let dir = tempfile::tempdir().unwrap(); + let events_path = dir.path().join("events").join("h.jsonl"); + std::fs::create_dir_all(events_path.parent().unwrap()).unwrap(); + let state_path = dir.path().join("h.sqlite"); + let project_hash = "h"; + + let mut writer = tj_core::storage::JsonlWriter::open(&events_path).unwrap(); + let mut open = Event::new( + "tj-1", + EventType::Open, + Author::User, + Source::Cli, + "task one".into(), + ); + open.meta = serde_json::json!({ "title": "task one" }); + open.timestamp = "2026-01-01T00:00:00Z".into(); + writer.append(&open).unwrap(); + let mut cons = Event::new( + "tj-1", + EventType::Constraint, + Author::Agent, + Source::Hook, + "API limit 100/min".into(), + ); + cons.timestamp = "2026-01-01T00:00:01Z".into(); + writer.append(&cons).unwrap(); + let mut find = Event::new( + "tj-1", + EventType::Finding, + Author::Agent, + Source::Hook, + "read http.rs".into(), + ); + find.timestamp = "2026-01-01T00:00:02Z".into(); + writer.append(&find).unwrap(); + writer.flush_durable().unwrap(); + + let conn = tj_core::db::open(&state_path).unwrap(); + tj_core::db::ingest_new_events(&conn, &events_path, project_hash).unwrap(); + + let ctxs = recent_task_contexts(&conn, 5).unwrap(); + let ctx = ctxs.iter().find(|c| c.task_id == "tj-1").unwrap(); + assert!( + ctx.constraints.iter().any(|s| s.contains("API limit 100/min")), + "constraints should include the constraint event, got {:?}", + ctx.constraints + ); + assert!( + !ctx.constraints.iter().any(|s| s.contains("read http.rs")), + "constraints must exclude non-constraint events, got {:?}", + ctx.constraints + ); + } + + #[test] + fn recent_task_contexts_bounds_constraints_to_n() { + use tj_core::event::{Author, Event, EventType, Source}; + let dir = tempfile::tempdir().unwrap(); + let events_path = dir.path().join("events").join("h.jsonl"); + std::fs::create_dir_all(events_path.parent().unwrap()).unwrap(); + let state_path = dir.path().join("h.sqlite"); + let project_hash = "h"; + + let mut writer = tj_core::storage::JsonlWriter::open(&events_path).unwrap(); + let mut open = Event::new( + "tj-1", + EventType::Open, + Author::User, + Source::Cli, + "task one".into(), + ); + open.meta = serde_json::json!({ "title": "task one" }); + open.timestamp = "2026-01-01T00:00:00Z".into(); + writer.append(&open).unwrap(); + for i in 0..7 { + let mut cons = Event::new( + "tj-1", + EventType::Constraint, + Author::Agent, + Source::Hook, + format!("constraint number {i}"), + ); + // Increasing timestamps so DESC ordering keeps the most recent. + cons.timestamp = format!("2026-01-01T00:00:1{i}Z"); + writer.append(&cons).unwrap(); + } + writer.flush_durable().unwrap(); + + let conn = tj_core::db::open(&state_path).unwrap(); + tj_core::db::ingest_new_events(&conn, &events_path, project_hash).unwrap(); + + let ctxs = recent_task_contexts(&conn, 5).unwrap(); + let ctx = ctxs.iter().find(|c| c.task_id == "tj-1").unwrap(); + assert_eq!(ctx.constraints.len(), 5, "bounded to CONSTRAINT_CONTEXT_LIMIT"); + // The 5 most recent are numbers 2..=6. + assert!(ctx.constraints.iter().any(|s| s.contains("constraint number 6"))); + assert!(!ctx.constraints.iter().any(|s| s.contains("constraint number 0"))); + assert!(!ctx.constraints.iter().any(|s| s.contains("constraint number 1"))); + } + #[test] fn topic_is_fts_safe_basic() { assert!(topic_is_fts_safe("oauth")); From acf6ac87f85938422565b7ae7471852543ecdb9d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:41:13 +0400 Subject: [PATCH 45/57] chore(constraint-context): CHANGELOG entry under 0.12.0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c959bdb..dbef772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.0] ### Added +- Constraint-as-context: the event classifier now sees each active task's most + recent `constraint` events (≤ 5) in its prompt, under a "Known constraints for + " block, with an instruction to prefer `rejection`/`correction` when a + chunk violates a constraint. Additive — tasks without constraints get the exact + prior prompt. - Push-recall: after a tool call, the PostToolUse hook surfaces a relevant prior `rejection`/`decision` via `additionalContext` ("⚠ recall: in task X you previously rejected …"), so the agent doesn't re-walk a ruled-out path. From 0f79d6ef959f4339e63e0ce583e9366edfda8f47 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:45:04 +0400 Subject: [PATCH 46/57] feat(reminder): pure active_task_reminder builder in tj-core Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/lib.rs | 1 + crates/tj-core/src/reminder.rs | 126 +++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 crates/tj-core/src/reminder.rs diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 0bbca1c..4d5ce2a 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -58,6 +58,7 @@ pub mod pack; pub mod paths; pub mod project_hash; pub mod recall; +pub mod reminder; pub mod session; pub mod session_id; pub mod storage; diff --git a/crates/tj-core/src/reminder.rs b/crates/tj-core/src/reminder.rs new file mode 100644 index 0000000..0117e8c --- /dev/null +++ b/crates/tj-core/src/reminder.rs @@ -0,0 +1,126 @@ +//! Compact, read-only reminder of the active task, re-injected after a +//! compaction so the post-compaction agent retains its task + constraints. + +use rusqlite::Connection; + +pub const MAX_CONSTRAINTS: usize = 3; + +/// Most-recent OPEN task → "title + goal + up to MAX_CONSTRAINTS newest +/// constraint texts". `None` when there is no open task. Read-only. +pub fn active_task_reminder(conn: &Connection) -> anyhow::Result> { + let row: Option<(String, String)> = conn + .query_row( + "SELECT task_id, title FROM tasks \ + WHERE status='open' ORDER BY last_event_at DESC LIMIT 1", + [], + |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)), + ) + .ok(); + let Some((task_id, title)) = row else { + return Ok(None); + }; + + let goal = crate::db::task_metadata(conn, &task_id)? + .and_then(|m| m.goal) + .filter(|g| !g.trim().is_empty()); + + // Same `events_index ei LEFT JOIN search_fts sf` shape the PreCompact + // marker query and the export-pr walk use; newest constraints first. + let mut stmt = conn.prepare( + "SELECT sf.text FROM events_index ei \ + LEFT JOIN search_fts sf ON sf.event_id = ei.event_id \ + WHERE ei.task_id = ?1 AND ei.type = 'constraint' \ + ORDER BY ei.timestamp DESC LIMIT ?2", + )?; + let constraints: Vec = stmt + .query_map( + rusqlite::params![task_id, MAX_CONSTRAINTS as i64], + |r| r.get::<_, Option>(0), + )? + .filter_map(|r| r.ok().flatten()) + .filter(|t| !t.trim().is_empty()) + .collect(); + + let mut out = format!("[Active task after compaction] {task_id} — {title}"); + if let Some(g) = goal { + out.push_str(&format!("\nGoal: {g}")); + } + if !constraints.is_empty() { + out.push_str("\nConstraints still in force:"); + for c in &constraints { + out.push_str(&format!("\n - {c}")); + } + } + Ok(Some(out)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::event::{Author, Event, EventStatus, EventType, Source}; + + const PH: &str = "ph-test"; + + fn open_event(task: &str, title: &str) -> Event { + let mut e = Event::new(task, EventType::Open, Author::User, Source::Cli, title.into()); + e.meta = serde_json::json!({ "title": title }); + e + } + + fn constraint_event(task: &str, text: &str, ts: &str) -> Event { + let mut e = Event::new(task, EventType::Constraint, Author::Agent, Source::Chat, text.into()); + e.status = EventStatus::Confirmed; + e.timestamp = ts.into(); + e + } + + fn seed(events: &[Event]) -> (tempfile::TempDir, rusqlite::Connection) { + let d = tempfile::TempDir::new().unwrap(); + let conn = db::open(d.path().join("s.sqlite")).unwrap(); + for e in events { + db::upsert_task_from_event(&conn, e, PH).unwrap(); + db::index_event(&conn, e).unwrap(); + } + (d, conn) + } + + #[test] + fn reminder_includes_title_goal_and_up_to_3_constraints() { + // Four constraints with ascending timestamps; only the 3 newest + // should appear, the oldest must be absent. + let events = vec![ + open_event("tj-1", "Build the widget"), + constraint_event("tj-1", "OLDEST: rate limit is 100/min", "2026-06-01T00:00:00Z"), + constraint_event("tj-1", "API key rotates daily", "2026-06-02T00:00:00Z"), + constraint_event("tj-1", "Must support offline mode", "2026-06-03T00:00:00Z"), + constraint_event("tj-1", "NEWEST: ship before Friday", "2026-06-04T00:00:00Z"), + ]; + let (_d, conn) = seed(&events); + db::set_task_goal(&conn, "tj-1", "Ship the dashboard widget").unwrap(); + + let r = active_task_reminder(&conn).unwrap().unwrap(); + assert!(r.starts_with("[Active task after compaction]"), "got: {r}"); + assert!(r.contains("Build the widget"), "got: {r}"); + assert!(r.contains("Goal: Ship the dashboard widget"), "got: {r}"); + assert!(r.contains("NEWEST: ship before Friday"), "got: {r}"); + assert!(r.contains("Must support offline mode"), "got: {r}"); + assert!(r.contains("API key rotates daily"), "got: {r}"); + assert!(!r.contains("OLDEST"), "oldest constraint leaked: {r}"); + } + + #[test] + fn reminder_none_when_no_open_task() { + let (_d, conn) = seed(&[]); + assert!(active_task_reminder(&conn).unwrap().is_none()); + } + + #[test] + fn reminder_none_when_task_closed() { + let mut close = Event::new("tj-1", EventType::Close, Author::User, Source::Cli, "done".into()); + close.timestamp = "2026-06-05T00:00:00Z".into(); + let events = vec![open_event("tj-1", "Build the widget"), close]; + let (_d, conn) = seed(&events); + assert!(active_task_reminder(&conn).unwrap().is_none()); + } +} From 7f5cca3fc03c8474d7fd1782018ba60c3404fe97 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:46:46 +0400 Subject: [PATCH 47/57] feat(reminder): SessionStart source=compact re-injects active task Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 11 +++++ crates/tj-cli/tests/cli.rs | 89 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index dcb8724..757f100 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1706,7 +1706,18 @@ fn main() -> Result<()> { if recent.is_empty() { return Ok(()); } + // After a compaction (source=="compact"), re-inject the + // active task + its in-force constraints so the rebuilt + // context doesn't lose what it was doing. Best-effort: + // any error → no reminder, never abort SessionStart. + let source = payload.get("source").and_then(|v| v.as_str()).unwrap_or(""); let mut bundle = String::new(); + if source == "compact" { + if let Ok(Some(reminder)) = tj_core::reminder::active_task_reminder(&conn) { + bundle.push_str(&reminder); + bundle.push_str("\n\n"); + } + } for tc in &recent { let pack = tj_core::pack::assemble( &conn, diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 83c2c3b..9c4ad95 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -4358,3 +4358,92 @@ fn push_recall_mcp_does_not_drop_capture() { "MCP tool call must still be captured into the journal, pack: {pack}" ); } + +// Seed a task with a goal + one constraint, run SessionStart with the +// given `source`, and return the parsed `additionalContext` string. +fn session_start_additional_context(source: &str) -> String { + 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", "Ship the widget", "--goal", "Ship the dashboard widget"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args([ + "event", + &task_id, + "--type", + "constraint", + "--text", + "Must ship before Friday", + ]) + .assert() + .success(); + + let stdin_payload = serde_json::json!({ + "hook_event_name": "SessionStart", + "source": source, + }) + .to_string(); + + let out = Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["ingest-hook", "--backend", "hybrid"]) + .write_stdin(stdin_payload) + .assert() + .success() + .get_output() + .stdout + .clone(); + let body = String::from_utf8(out).unwrap(); + let v: serde_json::Value = serde_json::from_str(body.trim()) + .unwrap_or_else(|e| panic!("SessionStart stdout must be JSON; got {body:?}; err {e}")); + v.get("hookSpecificOutput") + .and_then(|h| h.get("additionalContext")) + .and_then(|s| s.as_str()) + .expect("additionalContext must be present") + .to_string() +} + +#[test] +fn session_start_compact_prepends_active_task_reminder() { + let ctx = session_start_additional_context("compact"); + assert!( + ctx.starts_with("[Active task after compaction]"), + "compact SessionStart must lead with the reminder: {ctx}" + ); + assert!( + ctx.contains("Ship the widget"), + "reminder must include the task title: {ctx}" + ); + assert!( + ctx.contains("Goal: Ship the dashboard widget"), + "reminder must include the goal: {ctx}" + ); + assert!( + ctx.contains("Must ship before Friday"), + "reminder must include the in-force constraint: {ctx}" + ); +} + +#[test] +fn session_start_startup_has_no_reminder() { + let ctx = session_start_additional_context("startup"); + assert!( + !ctx.contains("[Active task after compaction]"), + "non-compact SessionStart must NOT inject the reminder: {ctx}" + ); +} From f90b359e05c51f8405f65c77761b4f044d49278f Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:47:39 +0400 Subject: [PATCH 48/57] docs(reminder): CHANGELOG entry under 0.12.0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbef772..ed4c0d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.0] ### Added +- Active-task reminder after compaction: when Claude Code reconstructs context + via a SessionStart with `source="compact"`, the journal now prepends the + most-recent open task's title + goal + up to 3 in-force `constraint` texts to + `additionalContext` — so the post-compaction agent doesn't lose what it was + doing or its hard constraints. Pure, read-only, best-effort (never breaks the + hook). New `tj_core::reminder::active_task_reminder`. Copies the mechanic of + the experimental `criticalSystemReminder` without depending on that unstable + field. - Constraint-as-context: the event classifier now sees each active task's most recent `constraint` events (≤ 5) in its prompt, under a "Known constraints for " block, with an instruction to prefer `rejection`/`correction` when a From e05eb7b29da185f74c6787f5c5c1dfe7632b2408 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:53:47 +0400 Subject: [PATCH 49/57] feat(memory-export): pure frontmatter::render_memory renderer Co-Authored-By: Claude Opus 4.8 --- crates/tj-core/src/frontmatter.rs | 145 ++++++++++++++++++++++++++++++ crates/tj-core/src/lib.rs | 1 + 2 files changed, 146 insertions(+) create mode 100644 crates/tj-core/src/frontmatter.rs diff --git a/crates/tj-core/src/frontmatter.rs b/crates/tj-core/src/frontmatter.rs new file mode 100644 index 0000000..2c14d8d --- /dev/null +++ b/crates/tj-core/src/frontmatter.rs @@ -0,0 +1,145 @@ +//! Pure renderer: a task's settled knowledge → a Claude-memory frontmatter file. +//! One-directional (Task-Journal → Claude memory). No fs, no DB, no JSONL. + +/// A task's settled knowledge, pre-fetched by the caller (CLI). +pub struct MemoryInput<'a> { + pub title: &'a str, + pub meta: &'a crate::db::TaskMetadata, + pub decisions: &'a [String], + pub constraints: &'a [String], +} + +/// Kebab-case slug: lowercase, non-alphanumeric runs → single `-`, trimmed. +pub fn slugify(title: &str) -> String { + let mut out = String::new(); + let mut prev_dash = true; // suppress leading dash + for c in title.chars() { + if c.is_alphanumeric() { + out.extend(c.to_lowercase()); + prev_dash = false; + } else if !prev_dash { + out.push('-'); + prev_dash = true; + } + } + while out.ends_with('-') { + out.pop(); + } + if out.is_empty() { + out.push_str("task"); + } + out +} + +/// One safe YAML double-quoted scalar: collapse whitespace/newlines to single +/// spaces, escape `\` and `"`. +fn yaml_quote(s: &str) -> String { + let collapsed = s.split_whitespace().collect::>().join(" "); + let escaped = collapsed.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +/// Render frontmatter + body. Empty sections are omitted. +pub fn render_memory(input: &MemoryInput<'_>) -> String { + let title = input.title; + let goal = input.meta.goal.as_deref().filter(|s| !s.is_empty()); + let description = goal.unwrap_or(title); + + let mut s = String::new(); + s.push_str("---\n"); + s.push_str(&format!("name: {}\n", slugify(title))); + s.push_str(&format!("description: {}\n", yaml_quote(description))); + s.push_str("metadata:\n type: project\n"); + s.push_str("---\n"); + + s.push_str(&format!("# {title}\n\n")); + s.push_str(&format!("**Goal:** {}\n", goal.unwrap_or("(not set)"))); + if let Some(o) = input.meta.outcome.as_deref().filter(|s| !s.is_empty()) { + s.push_str(&format!("**Outcome:** {o}\n")); + } + + if !input.decisions.is_empty() { + s.push_str("\n## Key decisions\n"); + for d in input.decisions { + s.push_str(&format!("- {d}\n")); + } + } + if !input.constraints.is_empty() { + s.push_str("\n## Constraints\n"); + for c in input.constraints { + s.push_str(&format!("- {c}\n")); + } + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugify_kebabs_and_trims() { + assert_eq!(slugify("Add Close Gate!"), "add-close-gate"); + assert_eq!(slugify(" Foo: Bar "), "foo-bar"); + } + + #[test] + fn render_has_frontmatter_block_with_type_project() { + let meta = crate::db::TaskMetadata { + goal: Some("Ship X".into()), + ..Default::default() + }; + let input = MemoryInput { + title: "Ship X", + meta: &meta, + decisions: &[], + constraints: &[], + }; + let out = render_memory(&input); + assert!(out.starts_with("---\n")); + assert!(out.contains("name: ship-x")); + assert!(out.contains("metadata:\n type: project")); + assert!(out.contains("\n---\n")); // closing fence + } + + #[test] + fn render_quotes_and_escapes_description() { + let meta = crate::db::TaskMetadata { + goal: Some("fix: a\nb \"q\"".into()), + ..Default::default() + }; + let input = MemoryInput { + title: "T", + meta: &meta, + decisions: &[], + constraints: &[], + }; + let out = render_memory(&input); + // description is one quoted scalar: newline collapsed, quotes escaped. + assert!(out.contains(r#"description: "fix: a b \"q\"""#)); + // no raw newline inside the frontmatter description value + let fm = out.split("\n---\n").next().unwrap(); + assert!(!fm.contains("fix: a\nb")); + } + + #[test] + fn render_omits_empty_sections_and_includes_filled_ones() { + let meta = crate::db::TaskMetadata { + goal: Some("G".into()), + outcome: Some("O".into()), + ..Default::default() + }; + let input = MemoryInput { + title: "T", + meta: &meta, + decisions: &["chose A".to_string()], + constraints: &[], + }; + let out = render_memory(&input); + assert!(out.contains("## Key decisions")); + assert!(out.contains("- chose A")); + assert!(!out.contains("## Constraints")); + assert!(out.contains("**Outcome:**")); + assert!(out.contains("O")); + } +} diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 4d5ce2a..fc813cb 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -53,6 +53,7 @@ pub mod completeness; pub mod db; pub mod dream; pub mod event; +pub mod frontmatter; pub mod fts; pub mod pack; pub mod paths; From c184eb6eef2acd3f188a197fc98c36711496449a Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 09:55:55 +0400 Subject: [PATCH 50/57] feat(memory-export): export-memory CLI writes Claude-memory frontmatter files Co-Authored-By: Claude Opus 4.8 --- crates/tj-cli/src/main.rs | 126 +++++++++++++++++++++++ crates/tj-cli/tests/cli.rs | 198 +++++++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 757f100..9552192 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -894,6 +894,18 @@ enum Commands { /// Why-this-approach, Verification, Affected). Reuses event log + /// artifacts; introduces no new tables. ExportPr { task_id: String }, + /// Export task knowledge as Claude-memory frontmatter files (feeds native dream). + ExportMemory { + /// Export a single task by id. + #[arg(long, conflicts_with = "all_closed")] + task: Option, + /// Export all closed tasks (default scope when no flag is given). + #[arg(long)] + all_closed: bool, + /// Print target paths + content without writing. + #[arg(long)] + dry_run: bool, + }, } #[derive(Subcommand)] @@ -2826,6 +2838,13 @@ fn main() -> Result<()> { Commands::ExportPr { task_id } => { run_export_pr(&task_id)?; } + Commands::ExportMemory { + task, + all_closed, + dry_run, + } => { + run_export_memory(task.as_deref(), all_closed, dry_run)?; + } } Ok(()) } @@ -3234,6 +3253,113 @@ fn run_export_pr(task_id: &str) -> Result<()> { Ok(()) } +fn run_export_memory(task: Option<&str>, _all_closed: bool, dry_run: bool) -> Result<()> { + const MAX_ITEMS: usize = 10; + + let cwd = std::env::current_dir()?; + let cwd_str = cwd.to_string_lossy().to_string(); + 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)?; + } + + // Resolve scope. + let task_ids: Vec = match task { + Some(id) => { + let exists: bool = conn + .query_row( + "SELECT 1 FROM tasks WHERE task_id = ?1", + rusqlite::params![id], + |_| Ok(true), + ) + .unwrap_or(false); + if !exists { + eprintln!("Error: task not found: {id}"); + std::process::exit(1); + } + vec![id.to_string()] + } + None => { + // default + --all-closed → all closed tasks + let mut stmt = + conn.prepare("SELECT task_id FROM tasks WHERE status='closed' ORDER BY task_id")?; + let ids = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + ids + } + }; + + if task_ids.is_empty() { + eprintln!("note: no closed tasks to export"); + return Ok(()); + } + + // Memory dir: ~/.claude/projects//memory/ + let memory_dir = tj_core::session::discovery::projects_dir()? + .join(tj_core::session::discovery::encode_project_path(&cwd_str)) + .join("memory"); + + for id in &task_ids { + let title: String = conn.query_row( + "SELECT title FROM tasks WHERE task_id = ?1", + rusqlite::params![id], + |r| r.get(0), + )?; + let meta = tj_core::db::task_metadata(&conn, id)?.unwrap_or_default(); + + // decision + constraint one-liners, oldest-first (== run_export_pr style). + 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 AND ei.type IN ('decision','constraint') + ORDER BY ei.timestamp ASC", + )?; + let rows = stmt.query_map(rusqlite::params![id], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, Option>(1)?.unwrap_or_default(), + )) + })?; + let mut decisions = Vec::new(); + let mut constraints = Vec::new(); + for row in rows { + let (ty, text) = row?; + let line = text.lines().next().unwrap_or("").trim().to_string(); + if line.is_empty() { + continue; + } + match ty.as_str() { + "decision" if decisions.len() < MAX_ITEMS => decisions.push(line), + "constraint" if constraints.len() < MAX_ITEMS => constraints.push(line), + _ => {} + } + } + + let slug = tj_core::frontmatter::slugify(&title); + let content = tj_core::frontmatter::render_memory(&tj_core::frontmatter::MemoryInput { + title: &title, + meta: &meta, + decisions: &decisions, + constraints: &constraints, + }); + let file_path = memory_dir.join(format!("tj-{id}-{slug}.md")); + + if dry_run { + println!("# would write: {}", file_path.display()); + println!("{content}"); + } else { + std::fs::create_dir_all(&memory_dir)?; + std::fs::write(&file_path, content)?; + println!("wrote {}", file_path.display()); + } + } + Ok(()) +} + /// How many of a task's most-recent `constraint` events to surface in /// the classifier prompt. Kept small so the prompt stays bounded. const CONSTRAINT_CONTEXT_LIMIT: i64 = 5; diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 9c4ad95..8a5326a 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -4447,3 +4447,201 @@ fn session_start_startup_has_no_reminder() { "non-compact SessionStart must NOT inject the reminder: {ctx}" ); } + +/// Recursively collect file names under `dir` that match a predicate. +/// Used to locate the sandboxed Claude-memory file without reconstructing +/// the exact `encode_project_path` transform of the test's cwd. +fn find_files_recursive(dir: &std::path::Path, pred: &dyn Fn(&str) -> bool) -> Vec { + let mut hits = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + hits.extend(find_files_recursive(&path, pred)); + } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if pred(name) { + hits.push(path.clone()); + } + } + } + } + hits +} + +#[test] +fn export_memory_dry_run_prints_path_and_content_no_write() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + let claude = assert_fs::TempDir::new().unwrap(); + + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Ship X", "--goal", "Ship X"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["close", &task_id, "--outcome", "done", "--outcome-tag", "done"]) + .assert() + .success(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .env("CLAUDE_CONFIG_DIR", claude.path()) + .current_dir(proj.path()) + .args(["export-memory", "--task", &task_id, "--dry-run"]) + .assert() + .success() + .stdout(contains(&format!("memory/tj-{task_id}-ship-x.md"))) + .stdout(contains("name: ship-x")); + + // Nothing written under the sandboxed Claude config dir. + let written = find_files_recursive(claude.path(), &|n| n.starts_with("tj-") && n.ends_with(".md")); + assert!(written.is_empty(), "dry-run must not write: {written:?}"); +} + +#[test] +fn export_memory_writes_one_idempotent_file() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + let claude = assert_fs::TempDir::new().unwrap(); + + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Ship X", "--goal", "Ship X"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["close", &task_id, "--outcome", "done", "--outcome-tag", "done"]) + .assert() + .success(); + + for _ in 0..2 { + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .env("CLAUDE_CONFIG_DIR", claude.path()) + .current_dir(proj.path()) + .args(["export-memory", "--task", &task_id]) + .assert() + .success(); + } + + let prefix = format!("tj-{task_id}-"); + let files = + find_files_recursive(claude.path(), &|n| n.starts_with(&prefix) && n.ends_with(".md")); + assert_eq!(files.len(), 1, "exactly one idempotent file: {files:?}"); + + let body = std::fs::read_to_string(&files[0]).unwrap(); + assert!(body.starts_with("---\n"), "frontmatter fence: {body}"); + assert!(body.contains("type: project"), "metadata type: {body}"); +} + +#[test] +fn export_memory_all_closed_skips_open() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + let claude = assert_fs::TempDir::new().unwrap(); + + let task_a = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Closed A", "--goal", "A"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["close", &task_a, "--outcome", "done", "--outcome-tag", "done"]) + .assert() + .success(); + + let task_b = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj.path()) + .args(["create", "Open B", "--goal", "B"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .env("CLAUDE_CONFIG_DIR", claude.path()) + .current_dir(proj.path()) + .args(["export-memory", "--all-closed"]) + .assert() + .success(); + + let for_a = + find_files_recursive(claude.path(), &|n| n.starts_with(&format!("tj-{task_a}-"))); + let for_b = + find_files_recursive(claude.path(), &|n| n.starts_with(&format!("tj-{task_b}-"))); + assert_eq!(for_a.len(), 1, "closed task A exported: {for_a:?}"); + assert!(for_b.is_empty(), "open task B must be skipped: {for_b:?}"); +} + +#[test] +fn export_memory_missing_task_exits_1() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + let claude = assert_fs::TempDir::new().unwrap(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .env("CLAUDE_CONFIG_DIR", claude.path()) + .current_dir(proj.path()) + .args(["export-memory", "--task", "tj-nope"]) + .assert() + .failure() + .code(1) + .stderr(contains("task not found")); +} From 8f9b50dcab83d89c3fc9534ba787b2c34b56afbb Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:15:02 +0400 Subject: [PATCH 51/57] docs(memory-export): CHANGELOG entry for export-memory under 0.12.0 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4c0d7..5fb0f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.12.0] ### Added +- `export-memory` command: distills a task's goal, outcome, and key + decisions/constraints into a Claude-memory frontmatter file under + `~/.claude/projects//memory/tj--.md`, feeding Claude's + native long-term memory + dream. Scope: `--task `, `--all-closed` + (default = all closed tasks); `--dry-run` prints without writing. + One-directional and idempotent; never reads Claude's memory or mutates the + append-only JSONL. - Active-task reminder after compaction: when Claude Code reconstructs context via a SessionStart with `source="compact"`, the journal now prepends the most-recent open task's title + goal + up to 3 in-force `constraint` texts to From 1f64017093d89254ec5da14c99bf7d139b30e6fd Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:15:02 +0400 Subject: [PATCH 52/57] chore: gitignore .docs/ (local specs + plans) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 403f43e..c19b5bd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ # Token Pilot local cache .token-pilot/ .token-pilot-fingerprint.json + +# Local design specs & plans (not versioned) +.docs/ From 17a0e19c08f01fbc111fb5b23082d4f5db160e97 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:28:53 +0400 Subject: [PATCH 53/57] style: cargo fmt --all across new feature code --- crates/tj-cli/src/main.rs | 90 +++++++++++++++++++++--------- crates/tj-cli/tests/cli.rs | 64 ++++++++++++++++----- crates/tj-core/src/completeness.rs | 50 +++++++++++------ crates/tj-core/src/dream/mod.rs | 30 ++++++++-- crates/tj-core/src/dream/scope.rs | 10 +--- crates/tj-core/src/pack.rs | 31 +++++++--- crates/tj-core/src/recall.rs | 23 ++++---- crates/tj-core/src/reminder.rs | 37 +++++++++--- 8 files changed, 241 insertions(+), 94 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 9552192..5aae176 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1690,8 +1690,7 @@ fn main() -> Result<()> { // mcp__ tools) — gated MCP-only, falls through to the queue path so // event capture is unaffected. Disabled by TJ_PUSH_RECALL=0. if kind == "PostToolUse" && std::env::var("TJ_PUSH_RECALL").as_deref() != Ok("0") { - if let Some(envelope) = - push_recall_envelope(&payload, &events_path, &project_hash) + if let Some(envelope) = push_recall_envelope(&payload, &events_path, &project_hash) { println!("{}", serde_json::to_string(&envelope)?); } @@ -2147,7 +2146,14 @@ fn main() -> Result<()> { .unwrap_or(false); let is_mock = mock_event_type.is_some() && mock_task_id.is_some(); if !is_mock && !force_sync { - let _ = persist_pending_v2(&events_path, &kind, &text, &project_hash, &backend, live_session_id.as_deref())?; + let _ = persist_pending_v2( + &events_path, + &kind, + &text, + &project_hash, + &backend, + live_session_id.as_deref(), + )?; // Fire-and-forget worker. Errors here are best-effort — // a failure to spawn just means the entry sits in // pending/ until the next hook fires another spawn. @@ -2378,10 +2384,8 @@ fn main() -> 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 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)?; // 1. Resolve session files in scope. @@ -3406,13 +3410,14 @@ fn recent_task_contexts( ORDER BY ei.timestamp DESC LIMIT ?2", )?; let constraints: Vec = c_stmt - .query_map( - rusqlite::params![task_id, CONSTRAINT_CONTEXT_LIMIT], - |r| { - let txt: Option = r.get(0)?; - Ok(txt.unwrap_or_default().chars().take(120).collect::()) - }, - )? + .query_map(rusqlite::params![task_id, CONSTRAINT_CONTEXT_LIMIT], |r| { + let txt: Option = r.get(0)?; + Ok(txt + .unwrap_or_default() + .chars() + .take(120) + .collect::()) + })? .collect::, _>>()? .into_iter() .filter(|s| !s.is_empty()) @@ -4357,13 +4362,23 @@ mod inline_tests { #[test] fn task_matches_by_session_id_or_time_window() { use tj_core::event::{Author, Event, EventType, Source}; - let mut tagged = - Event::new("tj-1", EventType::Finding, Author::Agent, Source::Hook, "x".into()); + let mut tagged = Event::new( + "tj-1", + EventType::Finding, + Author::Agent, + Source::Hook, + "x".into(), + ); tagged.meta = serde_json::json!({"session_id": "sess-1"}); assert!(task_matches_session(&[tagged], "sess-1", None, None)); - let mut legacy = - Event::new("tj-2", EventType::Finding, Author::Agent, Source::Hook, "y".into()); + let mut legacy = Event::new( + "tj-2", + EventType::Finding, + Author::Agent, + Source::Hook, + "y".into(), + ); legacy.timestamp = "2026-01-01T00:00:30Z".into(); legacy.meta = serde_json::json!({}); // no session_id assert!(task_matches_session( @@ -4386,8 +4401,15 @@ mod inline_tests { let dir = tempfile::tempdir().unwrap(); let events_path = dir.path().join("events").join("h.jsonl"); std::fs::create_dir_all(events_path.parent().unwrap()).unwrap(); - let p = persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", Some("sess-9")) - .unwrap(); + let p = persist_pending_v2( + &events_path, + "PostToolUse", + "txt", + "h", + "hybrid", + Some("sess-9"), + ) + .unwrap(); let body = std::fs::read_to_string(&p).unwrap(); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert_eq!(v["session_id"], serde_json::json!("sess-9")); @@ -4402,7 +4424,8 @@ mod inline_tests { let dir = tempfile::tempdir().unwrap(); let events_path = dir.path().join("events").join("h.jsonl"); std::fs::create_dir_all(events_path.parent().unwrap()).unwrap(); - let p = persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", None).unwrap(); + let p = + persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", None).unwrap(); let body = std::fs::read_to_string(&p).unwrap(); let v: serde_json::Value = serde_json::from_str(&body).unwrap(); assert!(v.get("session_id").is_none()); @@ -4476,7 +4499,9 @@ mod inline_tests { let ctxs = recent_task_contexts(&conn, 5).unwrap(); let ctx = ctxs.iter().find(|c| c.task_id == "tj-1").unwrap(); assert!( - ctx.constraints.iter().any(|s| s.contains("API limit 100/min")), + ctx.constraints + .iter() + .any(|s| s.contains("API limit 100/min")), "constraints should include the constraint event, got {:?}", ctx.constraints ); @@ -4526,11 +4551,24 @@ mod inline_tests { let ctxs = recent_task_contexts(&conn, 5).unwrap(); let ctx = ctxs.iter().find(|c| c.task_id == "tj-1").unwrap(); - assert_eq!(ctx.constraints.len(), 5, "bounded to CONSTRAINT_CONTEXT_LIMIT"); + assert_eq!( + ctx.constraints.len(), + 5, + "bounded to CONSTRAINT_CONTEXT_LIMIT" + ); // The 5 most recent are numbers 2..=6. - assert!(ctx.constraints.iter().any(|s| s.contains("constraint number 6"))); - assert!(!ctx.constraints.iter().any(|s| s.contains("constraint number 0"))); - assert!(!ctx.constraints.iter().any(|s| s.contains("constraint number 1"))); + assert!(ctx + .constraints + .iter() + .any(|s| s.contains("constraint number 6"))); + assert!(!ctx + .constraints + .iter() + .any(|s| s.contains("constraint number 0"))); + assert!(!ctx + .constraints + .iter() + .any(|s| s.contains("constraint number 1"))); } #[test] diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 8a5326a..f8c024d 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -4202,8 +4202,14 @@ fn post_tool_use_mcp_prepends_recall_banner() { .and_then(|h| h.get("updatedMCPToolOutput")) .and_then(|s| s.as_str()) .expect("updatedMCPToolOutput missing"); - assert!(updated.starts_with('\u{26a0}'), "must start with banner: {updated}"); - assert!(updated.contains("axum"), "banner must mention the recall: {updated}"); + assert!( + updated.starts_with('\u{26a0}'), + "must start with banner: {updated}" + ); + assert!( + updated.contains("axum"), + "banner must mention the recall: {updated}" + ); assert!( updated.contains("REAL TOOL OUTPUT 12345"), "original tool output must be preserved: {updated}" @@ -4367,7 +4373,12 @@ fn session_start_additional_context(source: &str) -> String { Command::cargo_bin("task-journal") .unwrap() .env("XDG_DATA_HOME", dir.path()) - .args(["create", "Ship the widget", "--goal", "Ship the dashboard widget"]) + .args([ + "create", + "Ship the widget", + "--goal", + "Ship the dashboard widget", + ]) .assert() .success() .get_output() @@ -4451,7 +4462,10 @@ fn session_start_startup_has_no_reminder() { /// Recursively collect file names under `dir` that match a predicate. /// Used to locate the sandboxed Claude-memory file without reconstructing /// the exact `encode_project_path` transform of the test's cwd. -fn find_files_recursive(dir: &std::path::Path, pred: &dyn Fn(&str) -> bool) -> Vec { +fn find_files_recursive( + dir: &std::path::Path, + pred: &dyn Fn(&str) -> bool, +) -> Vec { let mut hits = Vec::new(); if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { @@ -4494,7 +4508,14 @@ fn export_memory_dry_run_prints_path_and_content_no_write() { .unwrap() .env("XDG_DATA_HOME", xdg.path()) .current_dir(proj.path()) - .args(["close", &task_id, "--outcome", "done", "--outcome-tag", "done"]) + .args([ + "close", + &task_id, + "--outcome", + "done", + "--outcome-tag", + "done", + ]) .assert() .success(); @@ -4510,7 +4531,9 @@ fn export_memory_dry_run_prints_path_and_content_no_write() { .stdout(contains("name: ship-x")); // Nothing written under the sandboxed Claude config dir. - let written = find_files_recursive(claude.path(), &|n| n.starts_with("tj-") && n.ends_with(".md")); + let written = find_files_recursive(claude.path(), &|n| { + n.starts_with("tj-") && n.ends_with(".md") + }); assert!(written.is_empty(), "dry-run must not write: {written:?}"); } @@ -4540,7 +4563,14 @@ fn export_memory_writes_one_idempotent_file() { .unwrap() .env("XDG_DATA_HOME", xdg.path()) .current_dir(proj.path()) - .args(["close", &task_id, "--outcome", "done", "--outcome-tag", "done"]) + .args([ + "close", + &task_id, + "--outcome", + "done", + "--outcome-tag", + "done", + ]) .assert() .success(); @@ -4556,8 +4586,9 @@ fn export_memory_writes_one_idempotent_file() { } let prefix = format!("tj-{task_id}-"); - let files = - find_files_recursive(claude.path(), &|n| n.starts_with(&prefix) && n.ends_with(".md")); + let files = find_files_recursive(claude.path(), &|n| { + n.starts_with(&prefix) && n.ends_with(".md") + }); assert_eq!(files.len(), 1, "exactly one idempotent file: {files:?}"); let body = std::fs::read_to_string(&files[0]).unwrap(); @@ -4591,7 +4622,14 @@ fn export_memory_all_closed_skips_open() { .unwrap() .env("XDG_DATA_HOME", xdg.path()) .current_dir(proj.path()) - .args(["close", &task_a, "--outcome", "done", "--outcome-tag", "done"]) + .args([ + "close", + &task_a, + "--outcome", + "done", + "--outcome-tag", + "done", + ]) .assert() .success(); @@ -4620,10 +4658,8 @@ fn export_memory_all_closed_skips_open() { .assert() .success(); - let for_a = - find_files_recursive(claude.path(), &|n| n.starts_with(&format!("tj-{task_a}-"))); - let for_b = - find_files_recursive(claude.path(), &|n| n.starts_with(&format!("tj-{task_b}-"))); + let for_a = find_files_recursive(claude.path(), &|n| n.starts_with(&format!("tj-{task_a}-"))); + let for_b = find_files_recursive(claude.path(), &|n| n.starts_with(&format!("tj-{task_b}-"))); assert_eq!(for_a.len(), 1, "closed task A exported: {for_a:?}"); assert!(for_b.is_empty(), "open task B must be skipped: {for_b:?}"); } diff --git a/crates/tj-core/src/completeness.rs b/crates/tj-core/src/completeness.rs index a235af9..dddfb09 100644 --- a/crates/tj-core/src/completeness.rs +++ b/crates/tj-core/src/completeness.rs @@ -74,9 +74,7 @@ pub fn assess( let mut evidence = 0usize; let mut suggested = 0usize; { - let mut stmt = conn.prepare( - "SELECT type, status FROM events_index WHERE task_id = ?1", - )?; + let mut stmt = conn.prepare("SELECT type, status FROM events_index WHERE task_id = ?1")?; let rows = stmt.query_map(rusqlite::params![task_id], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) })?; @@ -108,8 +106,10 @@ pub fn assess( if pending_count > 0 { gaps.push(Gap { kind: GapKind::PendingLeak, - detail: format!("{pending_count} pending entr{} not yet classified", - if pending_count == 1 { "y" } else { "ies" }), + detail: format!( + "{pending_count} pending entr{} not yet classified", + if pending_count == 1 { "y" } else { "ies" } + ), }); } @@ -123,8 +123,7 @@ pub fn pending_count() -> usize { fn inner() -> anyhow::Result { let cwd = std::env::current_dir()?; let project_hash = crate::project_hash::from_path(&cwd)?; - let events_path = - crate::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let events_path = crate::paths::events_dir()?.join(format!("{project_hash}.jsonl")); let dir = events_path .parent() .and_then(|p| p.parent()) @@ -195,8 +194,10 @@ mod tests { let (_d, c) = conn(); open_task(&c, "t2"); // Set a goal, then close without outcome. - c.execute("UPDATE tasks SET goal='ship X' WHERE task_id='t2'", []).unwrap(); - c.execute("UPDATE tasks SET status='closed' WHERE task_id='t2'", []).unwrap(); + c.execute("UPDATE tasks SET goal='ship X' WHERE task_id='t2'", []) + .unwrap(); + c.execute("UPDATE tasks SET status='closed' WHERE task_id='t2'", []) + .unwrap(); let r = assess(&c, "t2", 0).unwrap(); assert!(r.gaps.iter().any(|g| g.kind == GapKind::ClosedNoOutcome)); assert!(!r.gaps.iter().any(|g| g.kind == GapKind::NoGoal)); @@ -214,14 +215,18 @@ mod tests { use crate::event::EventStatus; let (_d, c) = conn(); open_task(&c, "t3"); - c.execute("UPDATE tasks SET goal='g' WHERE task_id='t3'", []).unwrap(); + c.execute("UPDATE tasks SET goal='g' WHERE task_id='t3'", []) + .unwrap(); add_event(&c, "t3", EventType::Decision, EventStatus::Confirmed); let r = assess(&c, "t3", 0).unwrap(); assert!(r.gaps.iter().any(|g| g.kind == GapKind::DecisionNoEvidence)); add_event(&c, "t3", EventType::Evidence, EventStatus::Confirmed); let r2 = assess(&c, "t3", 0).unwrap(); - assert!(!r2.gaps.iter().any(|g| g.kind == GapKind::DecisionNoEvidence)); + assert!(!r2 + .gaps + .iter() + .any(|g| g.kind == GapKind::DecisionNoEvidence)); } #[test] @@ -229,11 +234,16 @@ mod tests { use crate::event::EventStatus; let (_d, c) = conn(); open_task(&c, "t4"); - c.execute("UPDATE tasks SET goal='g' WHERE task_id='t4'", []).unwrap(); + c.execute("UPDATE tasks SET goal='g' WHERE task_id='t4'", []) + .unwrap(); add_event(&c, "t4", EventType::Finding, EventStatus::Suggested); add_event(&c, "t4", EventType::Finding, EventStatus::Suggested); let r = assess(&c, "t4", 0).unwrap(); - let g = r.gaps.iter().find(|g| g.kind == GapKind::SuggestedUnconfirmed).unwrap(); + let g = r + .gaps + .iter() + .find(|g| g.kind == GapKind::SuggestedUnconfirmed) + .unwrap(); assert!(g.detail.contains('2')); } @@ -241,9 +251,14 @@ mod tests { fn pending_leak_fires_when_count_positive() { let (_d, c) = conn(); open_task(&c, "t5"); - c.execute("UPDATE tasks SET goal='g' WHERE task_id='t5'", []).unwrap(); + c.execute("UPDATE tasks SET goal='g' WHERE task_id='t5'", []) + .unwrap(); let r = assess(&c, "t5", 3).unwrap(); - let g = r.gaps.iter().find(|g| g.kind == GapKind::PendingLeak).unwrap(); + let g = r + .gaps + .iter() + .find(|g| g.kind == GapKind::PendingLeak) + .unwrap(); assert!(g.detail.contains('3')); let r0 = assess(&c, "t5", 0).unwrap(); @@ -266,7 +281,10 @@ mod tests { #[test] fn render_section_lists_gaps() { let r = CompletenessReport { - gaps: vec![Gap { kind: GapKind::NoGoal, detail: "no goal recorded".into() }], + gaps: vec![Gap { + kind: GapKind::NoGoal, + detail: "no goal recorded".into(), + }], }; let s = render_section(&r).unwrap(); assert!(s.contains("Completeness (1)")); diff --git a/crates/tj-core/src/dream/mod.rs b/crates/tj-core/src/dream/mod.rs index c8b9b54..1f83916 100644 --- a/crates/tj-core/src/dream/mod.rs +++ b/crates/tj-core/src/dream/mod.rs @@ -91,7 +91,13 @@ mod tests { let events_path = d.path().join("events.jsonl"); // Seed the task so upsert/index has a home (open event). - let open = Event::new("tj-1", EventType::Open, Author::User, Source::Cli, "Demo".into()); + let open = Event::new( + "tj-1", + EventType::Open, + Author::User, + Source::Cli, + "Demo".into(), + ); crate::db::upsert_task_from_event(&conn, &open, "ph").unwrap(); let backend = MockDreamBackend { @@ -114,8 +120,15 @@ mod tests { project_hash: "ph".into(), dry_run: false, }; - let report = - run_dream(&conn, &events_path, &opts, &backend, vec![task_input()], "run-1").unwrap(); + let report = run_dream( + &conn, + &events_path, + &opts, + &backend, + vec![task_input()], + "run-1", + ) + .unwrap(); assert_eq!(report.sessions_processed, 1); assert_eq!(report.events_backfilled, 1); // dup dropped @@ -135,8 +148,15 @@ mod tests { project_hash: "ph".into(), dry_run: true, }; - let report = - run_dream(&conn, &events_path, &opts, &backend, vec![task_input()], "run-1").unwrap(); + let report = run_dream( + &conn, + &events_path, + &opts, + &backend, + vec![task_input()], + "run-1", + ) + .unwrap(); assert_eq!(report.sessions_processed, 1); assert_eq!(report.events_backfilled, 0); assert!(!events_path.exists()); diff --git a/crates/tj-core/src/dream/scope.rs b/crates/tj-core/src/dream/scope.rs index 583a03e..b758116 100644 --- a/crates/tj-core/src/dream/scope.rs +++ b/crates/tj-core/src/dream/scope.rs @@ -60,10 +60,7 @@ mod tests { let r = in_scope(s, Some(at(150)), None); assert_eq!( r, - vec![ - std::path::PathBuf::from("c"), - std::path::PathBuf::from("b") - ] + vec![std::path::PathBuf::from("c"), std::path::PathBuf::from("b")] ); } @@ -86,10 +83,7 @@ mod tests { let r = in_scope(s, None, Some(2)); assert_eq!( r, - vec![ - std::path::PathBuf::from("c"), - std::path::PathBuf::from("b") - ] + vec![std::path::PathBuf::from("c"), std::path::PathBuf::from("b")] ); } } diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 9e64bec..00f5b82 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -493,8 +493,13 @@ mod tests { let d = tempfile::TempDir::new().unwrap(); let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); // Task with no goal → NoGoal gap. - let e = crate::event::Event::new("g1", crate::event::EventType::Open, - crate::event::Author::User, crate::event::Source::Cli, "T".into()); + let e = crate::event::Event::new( + "g1", + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "T".into(), + ); crate::db::upsert_task_from_event(&conn, &e, "ph").unwrap(); crate::db::index_event(&conn, &e).unwrap(); @@ -507,13 +512,19 @@ mod tests { fn pack_no_completeness_section_when_complete() { let d = tempfile::TempDir::new().unwrap(); let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); - let mut e = crate::event::Event::new("g2", crate::event::EventType::Open, - crate::event::Author::User, crate::event::Source::Cli, "T".into()); + let mut e = crate::event::Event::new( + "g2", + crate::event::EventType::Open, + crate::event::Author::User, + crate::event::Source::Cli, + "T".into(), + ); e.meta = serde_json::json!({"title": "T"}); crate::db::upsert_task_from_event(&conn, &e, "ph").unwrap(); crate::db::index_event(&conn, &e).unwrap(); // Give it a goal so NoGoal doesn't fire; open + no decisions → complete. - conn.execute("UPDATE tasks SET goal='g' WHERE task_id='g2'", []).unwrap(); + conn.execute("UPDATE tasks SET goal='g' WHERE task_id='g2'", []) + .unwrap(); // pending_count() resolves `/pending`. Point the data dir at // the isolated tempdir (no pending/ child) so the PendingLeak rule // stays silent regardless of the real dev environment. @@ -745,11 +756,17 @@ mod tests { let mut s = String::from("x"); s.push_str(&"я".repeat(2000)); // total = 1 + 4000 = 4001 bytes let budget = 100usize; // even → mid-char given the odd char starts - assert!(!s.is_char_boundary(budget), "precondition: budget must be mid-char"); + assert!( + !s.is_char_boundary(budget), + "precondition: budget must be mid-char" + ); truncate_to_budget(&mut s, budget, marker); // must NOT panic assert!(s.ends_with(marker)); assert!(s.len() <= budget + marker.len()); - assert!(std::str::from_utf8(s.as_bytes()).is_ok(), "result must be valid UTF-8"); + assert!( + std::str::from_utf8(s.as_bytes()).is_ok(), + "result must be valid UTF-8" + ); } #[test] diff --git a/crates/tj-core/src/recall.rs b/crates/tj-core/src/recall.rs index ac8d492..68179d2 100644 --- a/crates/tj-core/src/recall.rs +++ b/crates/tj-core/src/recall.rs @@ -25,9 +25,9 @@ pub const RELEVANCE_THRESHOLD: f64 = 1.0; /// high-frequency glue words plus the noise tokens that leak in from the /// synthesized tool-call JSON (`Bash: {"command": …}`). const STOPWORDS: &[&str] = &[ - "the", "and", "for", "with", "you", "are", "was", "but", "not", "this", - "that", "from", "have", "has", "had", "will", "your", "our", "out", "let", - "lets", "command", "output", "input", "tool", "bash", "name", "response", + "the", "and", "for", "with", "you", "are", "was", "but", "not", "this", "that", "from", "have", + "has", "had", "will", "your", "our", "out", "let", "lets", "command", "output", "input", + "tool", "bash", "name", "response", ]; /// Build an FTS5 OR-of-tokens query from a free-text context string. A raw @@ -162,12 +162,13 @@ pub fn relevant_recall( .into_iter() .filter(|(_, s)| *s >= RELEVANCE_THRESHOLD) .filter_map(|(eid, score)| { - meta.remove(&eid).map(|(task_id, event_type, text)| RecallHit { - task_id, - event_type, - text, - score, - }) + meta.remove(&eid) + .map(|(task_id, event_type, text)| RecallHit { + task_id, + event_type, + text, + score, + }) }) .collect(); hits.sort_by(|a, b| { @@ -320,7 +321,9 @@ mod tests { ); let (_d, conn) = seeded(&[rej]); - assert!(relevant_recall(&conn, "", DEFAULT_MAX_HITS).unwrap().is_empty()); + assert!(relevant_recall(&conn, "", DEFAULT_MAX_HITS) + .unwrap() + .is_empty()); assert!(relevant_recall(&conn, " ", DEFAULT_MAX_HITS) .unwrap() .is_empty()); diff --git a/crates/tj-core/src/reminder.rs b/crates/tj-core/src/reminder.rs index 0117e8c..2a01802 100644 --- a/crates/tj-core/src/reminder.rs +++ b/crates/tj-core/src/reminder.rs @@ -33,10 +33,9 @@ pub fn active_task_reminder(conn: &Connection) -> anyhow::Result> ORDER BY ei.timestamp DESC LIMIT ?2", )?; let constraints: Vec = stmt - .query_map( - rusqlite::params![task_id, MAX_CONSTRAINTS as i64], - |r| r.get::<_, Option>(0), - )? + .query_map(rusqlite::params![task_id, MAX_CONSTRAINTS as i64], |r| { + r.get::<_, Option>(0) + })? .filter_map(|r| r.ok().flatten()) .filter(|t| !t.trim().is_empty()) .collect(); @@ -63,13 +62,25 @@ mod tests { const PH: &str = "ph-test"; fn open_event(task: &str, title: &str) -> Event { - let mut e = Event::new(task, EventType::Open, Author::User, Source::Cli, title.into()); + let mut e = Event::new( + task, + EventType::Open, + Author::User, + Source::Cli, + title.into(), + ); e.meta = serde_json::json!({ "title": title }); e } fn constraint_event(task: &str, text: &str, ts: &str) -> Event { - let mut e = Event::new(task, EventType::Constraint, Author::Agent, Source::Chat, text.into()); + let mut e = Event::new( + task, + EventType::Constraint, + Author::Agent, + Source::Chat, + text.into(), + ); e.status = EventStatus::Confirmed; e.timestamp = ts.into(); e @@ -91,7 +102,11 @@ mod tests { // should appear, the oldest must be absent. let events = vec![ open_event("tj-1", "Build the widget"), - constraint_event("tj-1", "OLDEST: rate limit is 100/min", "2026-06-01T00:00:00Z"), + constraint_event( + "tj-1", + "OLDEST: rate limit is 100/min", + "2026-06-01T00:00:00Z", + ), constraint_event("tj-1", "API key rotates daily", "2026-06-02T00:00:00Z"), constraint_event("tj-1", "Must support offline mode", "2026-06-03T00:00:00Z"), constraint_event("tj-1", "NEWEST: ship before Friday", "2026-06-04T00:00:00Z"), @@ -117,7 +132,13 @@ mod tests { #[test] fn reminder_none_when_task_closed() { - let mut close = Event::new("tj-1", EventType::Close, Author::User, Source::Cli, "done".into()); + let mut close = Event::new( + "tj-1", + EventType::Close, + Author::User, + Source::Cli, + "done".into(), + ); close.timestamp = "2026-06-05T00:00:00Z".into(); let events = vec![open_event("tj-1", "Build the widget"), close]; let (_d, conn) = seed(&events); From cc0ff89bd8d539e206fd8dc60ba0de7e235d3258 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:29:06 +0400 Subject: [PATCH 54/57] fix(ci): cargo fmt --all + serialize XDG-mutating MCP test - cargo fmt --all over the epic's new code (fmt check was failing CI) - task_pack_returns_rpc_error_when_state_dir_is_unusable now holds the handler_env() mutex; it mutates the process-global XDG_DATA_HOME which the task_create/task_close handler tests read, so under parallel CI it poisoned their env mid-run and task_close_reports_completeness_gaps failed with an unrelated path error. --- crates/tj-mcp/src/main.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index c2239ed..748681f 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -520,9 +520,7 @@ impl TaskJournalServer { // v0.12.0: structured alternatives are decision-only. Reject // them on any other type with a clear error rather than // silently dropping the payload. - if p.alternatives.is_some() - && event_type != tj_core::event::EventType::Decision - { + if p.alternatives.is_some() && event_type != tj_core::event::EventType::Decision { anyhow::bail!( "`alternatives` is only valid on a `decision` event (got `{}`)", p.event_type @@ -756,7 +754,10 @@ mod tests { static LOCK: OnceLock> = OnceLock::new(); static HOME: OnceLock = OnceLock::new(); static PROJ: OnceLock = OnceLock::new(); - let guard = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap_or_else(|e| e.into_inner()); + let guard = LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()); let home = HOME.get_or_init(|| tempfile::TempDir::new().unwrap()); let proj = PROJ.get_or_init(|| tempfile::TempDir::new().unwrap()); std::env::set_var("XDG_DATA_HOME", home.path()); @@ -1172,6 +1173,12 @@ mod tests { #[test] fn task_pack_returns_rpc_error_when_state_dir_is_unusable() { + // This test mutates the process-global XDG_DATA_HOME, which the + // task_create/task_close handler tests read. Hold the same lock so + // it is serialized with them — otherwise it poisons their env mid-run + // and they fail with an unrelated path error (flaky under parallel CI). + let _env = handler_env(); + // Force tj_core::paths::state_dir to fail by pointing it at a path // that cannot be created. We do this through XDG_DATA_HOME pointing // at /dev/null which directories crate refuses. The handler must @@ -1183,9 +1190,6 @@ mod tests { // path via project_paths() and verify the conversion does the // right thing. let prev = std::env::var("XDG_DATA_HOME").ok(); - // SAFETY: this test does not run concurrently with other tests - // that read XDG_DATA_HOME — see the env-var test in tj-core for - // the same pattern. unsafe { std::env::set_var("XDG_DATA_HOME", "/dev/null/cannot-create-here"); } From 0056c9e54f48b04b7f40d343dec1f0a3ff8a188f Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:32:43 +0400 Subject: [PATCH 55/57] fix(ci): clear clippy warnings (-D warnings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - needless borrow (cli.rs test) + map_or->is_some_and (cli main) via clippy --fix - mcp tests: reword handler_env doc to drop the '+' markdown-list marker (doc_list_item_without_indentation), and #![allow(await_holding_lock)] on the tests module — the guard is intentionally held across .await to serialize handler tests on the current-thread runtime. --- crates/tj-cli/src/main.rs | 2 +- crates/tj-cli/tests/cli.rs | 2 +- crates/tj-mcp/src/main.rs | 11 ++++++++--- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 5aae176..5a44ee3 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -4325,7 +4325,7 @@ fn build_dream_inputs( )?; let tasks: Vec<_> = candidates .into_iter() - .filter(|t| task_filter.map_or(true, |f| f == t.task_id)) + .filter(|t| task_filter.is_none_or(|f| f == t.task_id)) .collect(); if tasks.is_empty() { continue; diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index f8c024d..ec8ea90 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -4527,7 +4527,7 @@ fn export_memory_dry_run_prints_path_and_content_no_write() { .args(["export-memory", "--task", &task_id, "--dry-run"]) .assert() .success() - .stdout(contains(&format!("memory/tj-{task_id}-ship-x.md"))) + .stdout(contains(format!("memory/tj-{task_id}-ship-x.md"))) .stdout(contains("name: ship-x")); // Nothing written under the sandboxed Claude config dir. diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 748681f..d8e2a54 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -743,12 +743,17 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { + // The handler tests intentionally hold the handler_env() mutex across + // `.await` to serialize access to the process-global PROJECT_DIR_OVERRIDE + // and XDG_DATA_HOME. On a current-thread runtime this is safe. + #![allow(clippy::await_holding_lock)] + use super::*; /// Handler tests touch process-global state (PROJECT_DIR_OVERRIDE OnceLock - /// + XDG_DATA_HOME env), so they must run one at a time and share a single - /// project dir. This guard serializes them and lazily pins the override + - /// XDG to a single persistent tempdir for the whole test binary. + /// and the XDG_DATA_HOME env var), so they must run one at a time and share + /// a single project dir. This guard serializes them and lazily pins the + /// override and XDG to a single persistent tempdir for the whole test binary. fn handler_env() -> std::sync::MutexGuard<'static, ()> { use std::sync::{Mutex, OnceLock}; static LOCK: OnceLock> = OnceLock::new(); From 2956a80c9f1667005df308616f78ebe6a1e0df2e Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:40:02 +0400 Subject: [PATCH 56/57] fix(ci): cross-platform export-memory test + restore MSRV toolchain pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - export_memory_dry_run test: assert filename + 'memory' separately instead of the 'memory/' literal — Windows prints a backslash separator. - ci: msrv job pins dtolnay/rust-toolchain@1.88 again (dependabot #17 bumped it to @1.100, which installs a non-existent rust 1.100.0 → 404). --- .github/workflows/ci.yml | 5 ++++- crates/tj-cli/tests/cli.rs | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d31e78..5cad8a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,10 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust 1.88 - uses: dtolnay/rust-toolchain@1.100 + # The action tag IS the toolchain version installed; @1.88 pins MSRV. + # (Dependabot bumped this to @1.100 in #17, which tries to install a + # non-existent rust 1.100.0 — revert to the real MSRV.) + uses: dtolnay/rust-toolchain@1.88 - name: Cache cargo uses: Swatinem/rust-cache@v2 diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index ec8ea90..27d2071 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -4527,7 +4527,9 @@ fn export_memory_dry_run_prints_path_and_content_no_write() { .args(["export-memory", "--task", &task_id, "--dry-run"]) .assert() .success() - .stdout(contains(format!("memory/tj-{task_id}-ship-x.md"))) + // Separator-agnostic: Windows prints `memory\tj-...` not `memory/tj-...`. + .stdout(contains(format!("tj-{task_id}-ship-x.md"))) + .stdout(contains("memory")) .stdout(contains("name: ship-x")); // Nothing written under the sandboxed Claude config dir. From e74dd25fcc3b3ccd1a31c2e9fb2842cd33a97863 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Thu, 11 Jun 2026 10:50:58 +0400 Subject: [PATCH 57/57] fix(ci): escape in rustdoc comments (invalid-html-tags -D warnings) cargo doc with RUSTDOCFLAGS=-D warnings rejected the bare in the create --parent / task_create parent doc comments as an unclosed HTML tag. Reworded to 'the given id'. --- crates/tj-cli/src/main.rs | 2 +- crates/tj-mcp/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 5a44ee3..3042286 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -595,7 +595,7 @@ enum Commands { /// with `task-journal goal ""`. #[arg(long)] goal: Option, - /// Parent task id — makes this a subtask of . + /// Parent task id — makes this a subtask of the given id. #[arg(long)] parent: Option, }, diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index d8e2a54..12c38a8 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -202,7 +202,7 @@ pub struct TaskCreateParams { /// "why was this done?" answers weeks later. Optional only for /// backwards compat; agents should always pass it. pub goal: Option, - /// Parent task id — makes this a subtask of . Validated: the + /// Parent task id — makes this a subtask of the given id. Validated: the /// parent must exist and the link must not introduce a cycle. pub parent: Option, }