From 8eb33221b599f99fb39e6c8a4a5271c30ba07035 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 16:46:20 +0400 Subject: [PATCH 01/23] feat(core): module event type, kept out of task views --- crates/tj-cli/src/main.rs | 43 ++++++++++++++++++++++++++++++++- crates/tj-cli/tests/cli.rs | 47 +++++++++++++++++++++++++++++++++++++ crates/tj-core/src/db.rs | 32 +++++++++++++++++++++++++ crates/tj-core/src/event.rs | 29 +++++++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index ec6b28e..56ef877 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1482,6 +1482,7 @@ fn real_main() -> Result<()> { return Ok(()); } let mut events = read_events_lenient(&events_path, "events list")?; + events.retain(|e| !e.is_module()); events.reverse(); for e in events.into_iter().take(limit) { let title = e @@ -3340,7 +3341,12 @@ runs in the background and won't block you; it only fills gaps and never closes anyhow::bail!("no events file at {events_path:?}"); } - let all_events = read_events_lenient(&events_path, "export")?; + let mut all_events = read_events_lenient(&events_path, "export")?; + // Module lines are the project's map, not a task: only the full + // JSON dump carries them. + if format != "json" { + all_events.retain(|e| !e.is_module()); + } // Filter to specific task if requested. let events: Vec<&tj_core::event::Event> = if let Some(ref tid) = task { @@ -6544,6 +6550,9 @@ fn events_by_task( continue; } if let Ok(e) = serde_json::from_str::(line) { + if e.is_module() { + continue; + } by_task.entry(e.task_id.clone()).or_default().push(e); } } @@ -6820,6 +6829,38 @@ mod inline_tests { assert!(miss.is_empty()); } + #[test] + fn events_by_task_skips_module_lines() { + // A module line has no session id: by the legacy time window it would + // pass for a task of every session it falls into. + use tj_core::event::{Author, Event, EventType, Source}; + let dir = tempfile::TempDir::new().unwrap(); + let log = dir.path().join("e.jsonl"); + let mut writer = tj_core::storage::JsonlWriter::open(&log).unwrap(); + for e in [ + Event::new( + "tj-1", + EventType::Open, + Author::User, + Source::Cli, + "T".into(), + ), + Event::new( + "mod:stars", + EventType::Module, + Author::Agent, + Source::Chat, + "Stars".into(), + ), + ] { + writer.append(&e).unwrap(); + } + + let by_task = events_by_task(&log).unwrap(); + + assert_eq!(by_task.keys().collect::>(), vec!["tj-1"]); + } + #[test] fn build_dream_inputs_skips_an_unreadable_session() { use tj_core::event::{Author, Event, EventType, Source}; diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 139d00d..ef76212 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -8489,3 +8489,50 @@ fn event_on_an_unknown_task_fails_and_writes_nothing() { assert_eq!(std::fs::read_to_string(&journal).unwrap(), before); } + +/// A `module` line (the project chronicle) describes the project's map, not a +/// task: task views skip it, the full JSON dump keeps it. +#[test] +fn module_lines_stay_out_of_task_views_but_not_json_export() { + let xdg = assert_fs::TempDir::new().unwrap(); + let proj = assert_fs::TempDir::new().unwrap(); + let tj = || { + let mut c = Command::cargo_bin("task-journal").unwrap(); + c.env("XDG_DATA_HOME", xdg.path()).current_dir(proj.path()); + c + }; + tj().args(["create", "T"]).assert().success(); + + let log = std::fs::read_dir(xdg.path().join("task-journal/events")) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.extension().is_some_and(|x| x == "jsonl")) + .unwrap(); + let mut e = tj_core::event::Event::new( + "mod:stars", + tj_core::event::EventType::Module, + tj_core::event::Author::Agent, + tj_core::event::Source::Chat, + "Stars".into(), + ); + e.meta = serde_json::json!({"module_id": "stars", "name": "Stars"}); + tj_core::storage::JsonlWriter::open(&log) + .unwrap() + .append(&e) + .unwrap(); + + tj().args(["events", "list"]) + .assert() + .success() + .stdout(contains("[module]").not()); + for format in ["md", "html"] { + tj().args(["export", "--format", format]) + .assert() + .success() + .stdout(contains("mod:stars").not()); + } + tj().args(["export", "--format", "json"]) + .assert() + .success() + .stdout(contains("mod:stars")); +} diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 04de66a..9da5e29 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -327,6 +327,10 @@ pub fn upsert_task_from_event( event: &Event, project_hash: &str, ) -> anyhow::Result<()> { + if event.event_type == EventType::Module { + return Ok(()); + } + match event.event_type { EventType::Open => { let title = event @@ -1108,6 +1112,10 @@ pub fn index_event(conn: &Connection, event: &Event) -> anyhow::Result<()> { if event.event_type == EventType::Amend { return invalidate_pack_cascade(conn, &event.task_id); } + // A module event describes the project's map, not a task. + if event.event_type == EventType::Module { + return Ok(()); + } let type_str = serde_json::to_value(event.event_type)? .as_str() @@ -1664,6 +1672,30 @@ mod tests { use crate::embed::Embedder; use tempfile::TempDir; + #[test] + fn module_event_creates_no_task_and_no_index_row() { + use crate::event::{Author, Source}; + + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + let e = Event::new( + "mod:stars", + EventType::Module, + Author::Agent, + Source::Chat, + "Stars".into(), + ); + + upsert_task_from_event(&conn, &e, "p").unwrap(); + index_event(&conn, &e).unwrap(); + + assert!(!task_exists(&conn, "mod:stars").unwrap()); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM events_index", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0); + } + #[test] fn task_exists_returns_true_for_known_id_false_otherwise() { let d = TempDir::new().unwrap(); diff --git a/crates/tj-core/src/event.rs b/crates/tj-core/src/event.rs index 6dae98c..4bc0fc9 100644 --- a/crates/tj-core/src/event.rs +++ b/crates/tj-core/src/event.rs @@ -22,6 +22,10 @@ pub enum EventType { /// Not reasoning — kept out of packs, search and recall. Written by the /// `goal` / `external` commands, never accepted as a user event type. Amend, + /// A module of the project chronicle (`task_id` = `mod:`): a partial + /// update of the module's fields in `meta`. Not a task's reasoning — kept + /// out of tasks, packs, search and recall. Written by `module_save`. + Module, } impl EventType { @@ -40,6 +44,7 @@ impl EventType { Self::Redirect, Self::Rename, Self::Amend, + Self::Module, ]; } @@ -114,6 +119,11 @@ pub struct Event { } impl Event { + /// A module's own event (`mod:`): project metadata, not a task's reasoning. + pub fn is_module(&self) -> bool { + self.event_type == EventType::Module + } + pub fn new( task_id: impl Into, event_type: EventType, @@ -161,6 +171,25 @@ mod tests { } } + #[test] + fn module_event_round_trips_and_is_recognised() { + let mut e = Event::new( + "mod:stars", + EventType::Module, + Author::Agent, + Source::Chat, + "Stars".into(), + ); + e.meta = serde_json::json!({"module_id": "stars", "name": "Stars"}); + + let line = serde_json::to_string(&e).unwrap(); + assert!(line.contains(r#""type":"module""#), "{line}"); + + let back: Event = serde_json::from_str(&line).unwrap(); + assert!(back.is_module()); + assert!(EventType::ALL.contains(&EventType::Module)); + } + #[test] fn author_source_status_strength_serialize_snake_case() { assert_eq!( From f976bc41d092a2abc075a1ae0c851d25392788f1 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 16:50:48 +0400 Subject: [PATCH 02/23] feat(core): project modules, task links and module history from the journal --- crates/tj-cli/src/main.rs | 4 + crates/tj-cli/tests/cli.rs | 17 +- crates/tj-core/src/db.rs | 71 +++- crates/tj-core/src/lib.rs | 1 + crates/tj-core/src/modules.rs | 651 ++++++++++++++++++++++++++++++++++ 5 files changed, 738 insertions(+), 6 deletions(-) create mode 100644 crates/tj-core/src/modules.rs diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 56ef877..b7c6474 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -292,8 +292,12 @@ fn run_migrate_project(from: &std::path::Path, to: &std::path::Path, force: bool "tasks", "index_state", "projection_state", + "projection_state_014", "embeddings", "dream_state", + "modules", + "task_modules", + "module_notes", ] { conn.execute( &format!("UPDATE {table} SET project_hash = ?1 WHERE project_hash = ?2"), diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index ef76212..8d1da06 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -990,12 +990,27 @@ fn migrate_project_rekeys_embeddings_and_dream_state() { [&from_hash], ) .unwrap(); + conn.execute_batch(&format!( + "INSERT INTO modules(project_hash, module_id, name, created_at, updated_at) + VALUES ('{from_hash}', 'stars', 'Stars', 't', 't'); + INSERT INTO task_modules(project_hash, task_id, module_id) + VALUES ('{from_hash}', 'tj-1', 'stars'); + INSERT INTO module_notes(project_hash, event_id, module_id, task_id, text, at) + VALUES ('{from_hash}', 'e1', 'stars', 'tj-1', 'x', 't');" + )) + .unwrap(); } migrate(xdg.path(), proj_a.path(), proj_b.path(), false); let conn = rusqlite::Connection::open(state.join(format!("{to_hash}.sqlite"))).unwrap(); - for table in ["dream_state", "embeddings"] { + for table in [ + "dream_state", + "embeddings", + "modules", + "task_modules", + "module_notes", + ] { let rekeyed: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {table} WHERE project_hash = ?1"), diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 9da5e29..58839ec 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -200,6 +200,49 @@ CREATE TABLE IF NOT EXISTS projection_state ( ALTER TABLE events_index ADD COLUMN author TEXT; "#; +/// v0.31.0 project chronicle: modules, task ↔ module links, module history +/// lines, and a projection mark only 0.31+ writes. 0.30 writes the older +/// marks while skipping `module` lines it cannot parse; a mismatch with this +/// one makes 0.31 replay the log, so no module is lost. +const MIGRATION_014: &str = r#" +CREATE TABLE IF NOT EXISTS modules ( + project_hash TEXT NOT NULL, + module_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + hints TEXT NOT NULL DEFAULT '{}', + state TEXT, + state_at TEXT, + status TEXT NOT NULL DEFAULT 'active', + merged_into TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (project_hash, module_id) +); +CREATE TABLE IF NOT EXISTS task_modules ( + project_hash TEXT NOT NULL, + task_id TEXT NOT NULL, + module_id TEXT NOT NULL, + PRIMARY KEY (task_id, module_id) +); +CREATE INDEX IF NOT EXISTS idx_task_modules_module ON task_modules(project_hash, module_id); +CREATE TABLE IF NOT EXISTS module_notes ( + project_hash TEXT NOT NULL, + event_id TEXT NOT NULL, + module_id TEXT NOT NULL, + task_id TEXT NOT NULL, + text TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (event_id, module_id) +); +CREATE INDEX IF NOT EXISTS idx_module_notes_module ON module_notes(project_hash, module_id, at); +CREATE TABLE IF NOT EXISTS projection_state_014 ( + project_hash TEXT PRIMARY KEY, + last_indexed_event_id 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] = &[ @@ -255,6 +298,10 @@ const MIGRATIONS: &[Migration] = &[ version: 13, sql: MIGRATION_013, }, + Migration { + version: 14, + sql: MIGRATION_014, + }, ]; fn apply_migrations(conn: &Connection) -> anyhow::Result<()> { @@ -327,6 +374,7 @@ pub fn upsert_task_from_event( event: &Event, project_hash: &str, ) -> anyhow::Result<()> { + crate::modules::project(conn, event, project_hash)?; if event.event_type == EventType::Module { return Ok(()); } @@ -904,8 +952,17 @@ pub fn task_artifacts( /// been indexed (first call, or a migration cleared the marker). type IndexMark = (String, String); +/// Marks every write records: `index_state` (all versions), +/// `projection_state` (0.30 — kept so a 0.30 binary goes on incrementally), +/// `projection_state_014` (0.31+, which projects modules). +const MARK_TABLES: [&str; 3] = ["index_state", "projection_state", "projection_state_014"]; + +/// The mark only this version writes: equal to `index_state` means nothing +/// older indexed since. +const OWN_MARK: &str = "projection_state_014"; + /// The mark stored in `table`: `index_state`, which every version writes, or -/// `projection_state`, the copy only this version writes. +/// [`OWN_MARK`], the copy only this version writes. fn read_mark( conn: &Connection, table: &str, @@ -931,7 +988,7 @@ fn record_last_indexed( event_id.to_string(), chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), ); - for table in ["index_state", "projection_state"] { + for table in MARK_TABLES { conn.execute( &format!( "INSERT INTO {table}(project_hash, last_indexed_event_id, updated_at) @@ -960,7 +1017,8 @@ const REPLAY_CHUNK: usize = 500; /// - No marker yet for this project (first call after a migration cleared /// it, or a brand-new install). /// - The marker is not the one this version last wrote: an older binary -/// ingested or rebuilt without the columns it does not know. +/// ingested or rebuilt without the columns or lines it does not know +/// (0.30 skips `module` lines). /// - The stored marker is not present in the JSONL (corrupted / truncated /// file). A `tracing::warn!` is emitted so the operator notices. pub fn ingest_new_events( @@ -1004,7 +1062,7 @@ fn replay( let mark = read_mark(&tx, "index_state", project_hash)?; if committed.is_none() { - let ours = mark == read_mark(&tx, "projection_state", project_hash)?; + let ours = mark == read_mark(&tx, OWN_MARK, project_hash)?; if mark.is_some() && !ours { tracing::warn!( project_hash = project_hash, @@ -1013,7 +1071,10 @@ fn replay( } match mark.filter(|_| !from_scratch && ours) { - None => clear_search_fts(&tx, project_hash)?, + None => { + clear_search_fts(&tx, project_hash)?; + crate::modules::clear(&tx, project_hash)?; + } Some((marker, _)) => { let mut found = false; for event in events.by_ref() { diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index e66599a..d34e513 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -71,6 +71,7 @@ pub mod fts; pub mod harvest; pub mod llm; pub mod memory; +pub mod modules; pub mod pack; pub mod paths; pub mod project_hash; diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs new file mode 100644 index 0000000..3589c58 --- /dev/null +++ b/crates/tj-core/src/modules.rs @@ -0,0 +1,651 @@ +//! The project chronicle: modules (parts of the system by meaning), which +//! tasks belong to them, and each module's history. Modules live in the +//! journal as `module` events (`task_id` = `mod:`, partial updates, the +//! last value of a field wins); links ride in `open` / `amend` / `close` +//! meta, which 0.30 already reads and ignores. + +use rusqlite::{Connection, OptionalExtension}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::event::{Author, Event, EventType, Source}; + +/// `task_id` prefix of a module's events; never collides with `tj-…`. +pub const ID_PREFIX: &str = "mod:"; + +const MAX_ID: usize = 48; + +/// What marks a module in a task: code path prefixes and the module's terms. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Hints { + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub terms: Vec, +} + +/// A partial update of a module: `None` leaves the field as it is. +#[derive(Debug, Clone, Default)] +pub struct ModuleFields { + pub name: Option, + pub description: Option, + pub hints: Option, + pub state: Option, + pub status: Option, + pub merged_into: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Module { + pub module_id: String, + pub name: String, + pub description: Option, + pub hints: Hints, + /// How the module works now: a short living text the AI rewrites. + pub state: Option, + pub state_at: Option, + pub status: String, + pub merged_into: Option, + pub task_count: i64, + pub last_activity: Option, +} + +/// Module ids ready to link, with what was redirected on the way. +#[derive(Debug, Default)] +pub struct Resolved { + pub ids: Vec, + pub warnings: Vec, +} + +pub fn validate_id(id: &str) -> anyhow::Result<()> { + let mut chars = id.chars(); + let is_first_ok = chars + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); + let is_rest_ok = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); + if !is_first_ok || !is_rest_ok || id.len() > MAX_ID { + anyhow::bail!( + "invalid module id {id:?}: use lowercase latin letters, digits and dashes, \ + starting with a letter or digit, at most {MAX_ID} chars (e.g. `stars`, `auth-refresh`)" + ); + } + + Ok(()) +} + +/// The journal event that creates or updates `module_id` with the given fields. +pub fn module_event(module_id: &str, f: &ModuleFields) -> anyhow::Result { + validate_id(module_id)?; + if let Some(status) = f.status.as_deref() { + if !["active", "retired", "merged"].contains(&status) { + anyhow::bail!("module status must be active, retired or merged, not {status:?}"); + } + if status == "merged" && f.merged_into.is_none() { + anyhow::bail!("status merged needs merged_into: the module that took over"); + } + } + + let text = f + .name + .clone() + .or_else(|| f.description.clone()) + .unwrap_or_else(|| format!("module {module_id} updated")); + let mut event = Event::new( + format!("{ID_PREFIX}{module_id}"), + EventType::Module, + Author::Agent, + Source::Chat, + text, + ); + + let mut meta = serde_json::json!({ "module_id": module_id }); + let fields = [ + ("name", &f.name), + ("description", &f.description), + ("state", &f.state), + ("status", &f.status), + ("merged_into", &f.merged_into), + ]; + for (key, value) in fields { + if let Some(v) = value { + meta[key] = serde_json::Value::String(v.clone()); + } + } + if let Some(h) = &f.hints { + meta["hints"] = serde_json::to_value(h)?; + } + event.meta = meta; + + Ok(event) +} + +/// The `amend` that links `task_id` to `add` and unlinks it from `remove`. +pub fn link_event(task_id: &str, add: &[String], remove: &[String]) -> Event { + let mut event = Event::new( + task_id, + EventType::Amend, + Author::Agent, + Source::Chat, + "modules changed".to_string(), + ); + event.meta = serde_json::json!({ "modules_add": add, "modules_remove": remove }); + + event +} + +/// The value of `close.meta.module_notes`: one history line per module. +pub fn notes_meta(notes: &[(String, String)]) -> serde_json::Value { + notes + .iter() + .map(|(module, text)| serde_json::json!({ "module": module, "text": text })) + .collect() +} + +/// Apply what `event` says about modules to the derived state. Called for +/// every event before its task projection. +pub fn project(conn: &Connection, event: &Event, project_hash: &str) -> anyhow::Result<()> { + match event.event_type { + EventType::Module => apply_module(conn, event, project_hash), + EventType::Open => link( + conn, + project_hash, + &event.task_id, + &strings(&event.meta, "modules"), + &[], + ), + EventType::Amend => link( + conn, + project_hash, + &event.task_id, + &strings(&event.meta, "modules_add"), + &strings(&event.meta, "modules_remove"), + ), + EventType::Close => add_notes(conn, project_hash, event), + _ => Ok(()), + } +} + +fn strings(meta: &serde_json::Value, key: &str) -> Vec { + meta.get(key) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn apply_module(conn: &Connection, event: &Event, project_hash: &str) -> anyhow::Result<()> { + let Some(id) = event.task_id.strip_prefix(ID_PREFIX) else { + return Ok(()); + }; + if validate_id(id).is_err() { + tracing::warn!( + task_id = event.task_id.as_str(), + "skipping a module event with an invalid id" + ); + return Ok(()); + } + + let field = |k: &str| { + event + .meta + .get(k) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + let hints = event.meta.get("hints").map(|v| v.to_string()); + conn.execute( + "INSERT INTO modules(project_hash, module_id, name, description, hints, state, state_at, + status, merged_into, created_at, updated_at) + VALUES (?1, ?2, COALESCE(?3, ?2), ?4, COALESCE(?5, '{}'), ?6, + CASE WHEN ?6 IS NULL THEN NULL ELSE ?9 END, COALESCE(?7, 'active'), ?8, ?9, ?9) + ON CONFLICT(project_hash, module_id) DO UPDATE SET + name = COALESCE(?3, name), + description = COALESCE(?4, description), + hints = COALESCE(?5, hints), + state = COALESCE(?6, state), + state_at = CASE WHEN ?6 IS NULL THEN state_at ELSE ?9 END, + status = COALESCE(?7, status), + merged_into = COALESCE(?8, merged_into), + updated_at = ?9", + rusqlite::params![ + project_hash, + id, + field("name"), + field("description"), + hints, + field("state"), + field("status"), + field("merged_into"), + event.timestamp + ], + )?; + + Ok(()) +} + +fn link( + conn: &Connection, + project_hash: &str, + task_id: &str, + add: &[String], + remove: &[String], +) -> anyhow::Result<()> { + for m in add { + conn.execute( + "INSERT OR IGNORE INTO task_modules(project_hash, task_id, module_id) VALUES (?1, ?2, ?3)", + rusqlite::params![project_hash, task_id, m], + )?; + } + for m in remove { + conn.execute( + "DELETE FROM task_modules WHERE task_id = ?1 AND module_id = ?2", + rusqlite::params![task_id, m], + )?; + } + + // The pack names the task's modules. + if !add.is_empty() || !remove.is_empty() { + crate::db::invalidate_pack_cascade(conn, task_id)?; + } + + Ok(()) +} + +fn add_notes(conn: &Connection, project_hash: &str, event: &Event) -> anyhow::Result<()> { + let Some(notes) = event.meta.get("module_notes").and_then(|v| v.as_array()) else { + return Ok(()); + }; + + for note in notes { + let module = note.get("module").and_then(|v| v.as_str()); + let text = note.get("text").and_then(|v| v.as_str()); + let (Some(module), Some(text)) = (module, text) else { + continue; + }; + + conn.execute( + "INSERT OR IGNORE INTO module_notes(project_hash, event_id, module_id, task_id, text, at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + project_hash, + event.event_id, + module, + event.task_id, + text, + event.timestamp + ], + )?; + // A task that writes a module's history belongs to it. + link( + conn, + project_hash, + &event.task_id, + &[module.to_string()], + &[], + )?; + } + + Ok(()) +} + +/// Drop this project's module rows before a replay re-creates them. +pub fn clear(conn: &Connection, project_hash: &str) -> anyhow::Result<()> { + for table in ["modules", "task_modules", "module_notes"] { + conn.execute( + &format!("DELETE FROM {table} WHERE project_hash = ?1"), + [project_hash], + )?; + } + + Ok(()) +} + +const MODULE_SELECT: &str = "SELECT m.module_id, m.name, m.description, m.hints, m.state, + m.state_at, m.status, m.merged_into, + (SELECT COUNT(*) FROM task_modules tm + WHERE tm.project_hash = m.project_hash AND tm.module_id = m.module_id), + (SELECT MAX(t.last_event_at) FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id + WHERE tm.project_hash = m.project_hash AND tm.module_id = m.module_id) + FROM modules m WHERE m.project_hash = ?1"; + +fn module_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let hints: String = r.get(3)?; + + Ok(Module { + module_id: r.get(0)?, + name: r.get(1)?, + description: r.get(2)?, + hints: serde_json::from_str(&hints).unwrap_or_default(), + state: r.get(4)?, + state_at: r.get(5)?, + status: r.get(6)?, + merged_into: r.get(7)?, + task_count: r.get(8)?, + last_activity: r.get(9)?, + }) +} + +pub fn get(conn: &Connection, project_hash: &str, id: &str) -> anyhow::Result> { + Ok(conn + .query_row( + &format!("{MODULE_SELECT} AND m.module_id = ?2"), + rusqlite::params![project_hash, id], + module_row, + ) + .optional()?) +} + +/// The project's modules, active ones first, then by id. +pub fn list(conn: &Connection, project_hash: &str) -> anyhow::Result> { + let mut stmt = conn.prepare(&format!( + "{MODULE_SELECT} ORDER BY m.status = 'active' DESC, m.module_id" + ))?; + let rows = stmt + .query_map([project_hash], module_row)? + .collect::>()?; + + Ok(rows) +} + +/// `(module_id, name)` of every module `task_id` belongs to. +pub fn modules_of_task(conn: &Connection, task_id: &str) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT tm.module_id, COALESCE(m.name, tm.module_id) FROM task_modules tm + LEFT JOIN modules m ON m.project_hash = tm.project_hash AND m.module_id = tm.module_id + WHERE tm.task_id = ?1 ORDER BY tm.module_id", + )?; + let rows = stmt + .query_map([task_id], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + + Ok(rows) +} + +/// Check module ids before a write links a task to them: an unknown id is +/// an error, a merged module stands for the one that took it over. +pub fn resolve(conn: &Connection, project_hash: &str, ids: &[String]) -> anyhow::Result { + let mut out = Resolved::default(); + + for id in ids { + validate_id(id)?; + let Some(m) = get(conn, project_hash, id)? else { + anyhow::bail!( + "module {id:?} does not exist — create it first with module_save (module_list shows the map)" + ); + }; + + let target = match (m.status.as_str(), m.merged_into) { + ("merged", Some(into)) => { + out.warnings.push(format!( + "module {id} was merged into {into}; linked to {into}" + )); + into + } + _ => m.module_id, + }; + if !out.ids.contains(&target) { + out.ids.push(target); + } + } + + Ok(out) +} + +#[cfg(test)] +pub(crate) mod tests_support { + use rusqlite::Connection; + use tempfile::TempDir; + + use crate::event::{Author, Event, EventType, Source}; + + /// A fresh state rebuilt from a journal holding `events`, in order. + pub(crate) fn journal(events: &[Event]) -> (TempDir, Connection) { + let d = TempDir::new().unwrap(); + let log = d.path().join("e.jsonl"); + let mut w = crate::storage::JsonlWriter::open(&log).unwrap(); + for e in events { + w.append(e).unwrap(); + } + + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + crate::db::rebuild_state(&conn, &log, "p").unwrap(); + + (d, conn) + } + + pub(crate) fn open_task(id: &str, modules: &[&str]) -> Event { + let mut e = Event::new(id, EventType::Open, Author::Agent, Source::Chat, id.into()); + e.meta = serde_json::json!({"title": id, "modules": modules}); + + e + } + + pub(crate) fn close_task(id: &str, meta: serde_json::Value) -> Event { + let mut e = Event::new( + id, + EventType::Close, + Author::Agent, + Source::Chat, + "done".into(), + ); + e.meta = meta; + + e + } +} + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + use tempfile::TempDir; + + use super::tests_support::{close_task, journal, open_task}; + use super::*; + use crate::event::{Author, Event, EventType, Source}; + + fn named(id: &str, name: &str) -> Event { + module_event( + id, + &ModuleFields { + name: Some(name.into()), + ..Default::default() + }, + ) + .unwrap() + } + + fn count(conn: &Connection, table: &str) -> i64 { + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap() + } + + #[test] + fn rebuild_restores_modules_links_and_notes() { + let state = module_event( + "stars", + &ModuleFields { + state: Some("Feed ranks by score".into()), + ..Default::default() + }, + ) + .unwrap(); + let close = close_task( + "tj-a", + serde_json::json!({ + "module_notes": notes_meta(&[("stars".into(), "Added ranking".into())]) + }), + ); + + let (_d, conn) = journal(&[ + named("stars", "Stars"), + state, + open_task("tj-a", &["stars"]), + close, + ]); + + let m = get(&conn, "p", "stars").unwrap().unwrap(); + // The later partial event changed the state and kept the name. + assert_eq!(m.name, "Stars"); + assert_eq!(m.state.as_deref(), Some("Feed ranks by score")); + assert!(m.state_at.is_some()); + assert_eq!(m.status, "active"); + assert_eq!(m.task_count, 1); + assert_eq!( + modules_of_task(&conn, "tj-a").unwrap(), + vec![("stars".to_string(), "Stars".to_string())] + ); + assert_eq!(count(&conn, "module_notes"), 1); + } + + #[test] + fn rebuild_twice_does_not_duplicate() { + let close = close_task( + "tj-a", + serde_json::json!({"module_notes": notes_meta(&[("stars".into(), "x".into())])}), + ); + let d = TempDir::new().unwrap(); + let log = d.path().join("e.jsonl"); + let mut w = crate::storage::JsonlWriter::open(&log).unwrap(); + for e in [ + named("stars", "Stars"), + open_task("tj-a", &["stars"]), + close, + ] { + w.append(&e).unwrap(); + } + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + + crate::db::rebuild_state(&conn, &log, "p").unwrap(); + crate::db::rebuild_state(&conn, &log, "p").unwrap(); + + assert_eq!(count(&conn, "modules"), 1); + assert_eq!(count(&conn, "module_notes"), 1); + assert_eq!(count(&conn, "task_modules"), 1); + } + + #[test] + fn amend_adds_and_removes_links() { + let (_d, conn) = journal(&[ + named("a", "A"), + named("b", "B"), + open_task("tj-a", &["a"]), + link_event("tj-a", &["b".into()], &["a".into()]), + ]); + + assert_eq!( + modules_of_task(&conn, "tj-a").unwrap(), + vec![("b".to_string(), "B".to_string())] + ); + } + + #[test] + fn a_note_links_its_task_to_the_module() { + let close = close_task( + "tj-a", + serde_json::json!({"module_notes": notes_meta(&[("a".into(), "x".into())])}), + ); + + let (_d, conn) = journal(&[named("a", "A"), open_task("tj-a", &[]), close]); + + assert_eq!(modules_of_task(&conn, "tj-a").unwrap().len(), 1); + } + + #[test] + fn resolve_rejects_unknown_and_redirects_merged() { + let old = module_event( + "old", + &ModuleFields { + name: Some("Old".into()), + status: Some("merged".into()), + merged_into: Some("a".into()), + ..Default::default() + }, + ) + .unwrap(); + let (_d, conn) = journal(&[named("a", "A"), old]); + + let r = resolve(&conn, "p", &["old".into(), "a".into()]).unwrap(); + assert_eq!(r.ids, vec!["a".to_string()]); + assert!(r.warnings[0].contains("merged into a"), "{:?}", r.warnings); + + let err = resolve(&conn, "p", &["nope".into()]) + .unwrap_err() + .to_string(); + assert!(err.contains("module_save"), "{err}"); + } + + #[test] + fn bad_slugs_are_rejected_with_an_example() { + let long = "x".repeat(49); + for bad in ["", "Stars", "-x", "a b", long.as_str(), "мод"] { + let err = validate_id(bad).unwrap_err().to_string(); + assert!(err.contains("auth-refresh"), "{bad}: {err}"); + } + + validate_id("auth-refresh").unwrap(); + validate_id(&"x".repeat(48)).unwrap(); + } + + #[test] + fn merged_without_a_target_is_refused() { + let err = module_event( + "old", + &ModuleFields { + status: Some("merged".into()), + ..Default::default() + }, + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("merged_into"), "{err}"); + } + + #[test] + fn a_module_line_indexed_past_by_an_older_binary_is_still_projected() { + // 0.30 cannot parse a `module` line: it indexes past it and writes + // its own marks. 0.31 must notice the foreign marker and replay. + let d = TempDir::new().unwrap(); + let log = d.path().join("e.jsonl"); + let mut w = crate::storage::JsonlWriter::open(&log).unwrap(); + let after = Event::new( + "tj-a", + EventType::Finding, + Author::Agent, + Source::Chat, + "written after the module line".into(), + ); + for e in [ + open_task("tj-a", &[]), + named("stars", "Stars"), + after.clone(), + ] { + w.append(&e).unwrap(); + } + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + crate::db::ingest_new_events(&conn, &log, "p").unwrap(); + + // What 0.30 leaves behind once it indexed the tail: no module row, + // index_state and projection_state on the last line it parsed, the + // 0.31 mark where 0.31 left it. + conn.execute_batch("DELETE FROM modules;").unwrap(); + for table in ["index_state", "projection_state"] { + conn.execute( + &format!("UPDATE {table} SET last_indexed_event_id = ?1"), + [&after.event_id], + ) + .unwrap(); + } + conn.execute( + "UPDATE projection_state_014 SET last_indexed_event_id = 'before-0.30'", + [], + ) + .unwrap(); + crate::db::ingest_new_events(&conn, &log, "p").unwrap(); + + assert!(get(&conn, "p", "stars").unwrap().is_some()); + } +} From 0a934b6b360bf85d3079bc84f1d89633401effdf Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 16:52:18 +0400 Subject: [PATCH 03/23] feat(core): suggest modules for a task without a model --- crates/tj-core/src/modules.rs | 346 ++++++++++++++++++++++++++++++++++ crates/tj-core/src/recall.rs | 17 +- 2 files changed, 357 insertions(+), 6 deletions(-) diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index 3589c58..e060ecc 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -394,6 +394,214 @@ pub fn resolve(conn: &Connection, project_hash: &str, ids: &[String]) -> anyhow: Ok(out) } +/// A suggestion needs two shared words, a term, or a file under the module. +const MIN_SCORE: u32 = 2; + +const MAX_SUGGESTIONS: usize = 3; + +/// Titles of a module's latest tasks that join its vocabulary. +const TITLES_PER_MODULE: i64 = 50; + +/// Russian glue words; recall's stopword list is English only. +const RU_STOPWORDS: &[&str] = &[ + "для", + "при", + "что", + "как", + "это", + "или", + "над", + "под", + "без", + "все", + "его", + "она", + "они", + "так", + "чтобы", +]; + +/// A module the journal thinks a task belongs to. Only a hint: the AI decides. +#[derive(Debug, Clone, Serialize)] +pub struct Suggestion { + pub module_id: String, + pub name: String, + pub score: u32, + /// What matched: files, terms, words. + pub why: String, +} + +// ponytail: a 5-char prefix stands in for stemming (EN/RU inflections); +// swap in a real stemmer if suggestions turn noisy. +fn stems(text: &str) -> std::collections::BTreeSet { + crate::recall::keywords(text) + .into_iter() + .filter(|w| !RU_STOPWORDS.contains(&w.as_str())) + .map(|w| w.chars().take(5).collect()) + .collect() +} + +fn is_under(file: &str, prefix: &str) -> bool { + let prefix = prefix.trim_start_matches("./"); + + !prefix.is_empty() && file.trim_start_matches("./").starts_with(prefix) +} + +fn task_titles( + conn: &Connection, + project_hash: &str, + module_id: &str, +) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT t.title FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id + WHERE tm.project_hash = ?1 AND tm.module_id = ?2 + ORDER BY t.last_event_at DESC LIMIT ?3", + )?; + let rows = stmt + .query_map( + rusqlite::params![project_hash, module_id, TITLES_PER_MODULE], + |r| r.get(0), + )? + .collect::>()?; + + Ok(rows) +} + +/// Active modules `text` and `files` point to, best first, at most three. +/// No model: files under the module's paths, its terms, shared words. +pub fn suggest( + conn: &Connection, + project_hash: &str, + text: &str, + files: &[String], +) -> anyhow::Result> { + let words = stems(text); + let lower = text.to_lowercase(); + let mut out = Vec::new(); + + for m in list(conn, project_hash)? + .into_iter() + .filter(|m| m.status == "active") + { + let mut score = 0; + let mut why = Vec::new(); + + let hit: Vec<&str> = files + .iter() + .map(String::as_str) + .filter(|f| m.hints.paths.iter().any(|p| is_under(f, p))) + .take(3) + .collect(); + if !hit.is_empty() { + score += 3 * hit.len() as u32; + why.push(format!("files: {}", hit.join(", "))); + } + + let terms: Vec<&str> = m + .hints + .terms + .iter() + .map(String::as_str) + .filter(|t| t.chars().count() >= 3 && lower.contains(&t.to_lowercase())) + .collect(); + if !terms.is_empty() { + score += 2 * terms.len() as u32; + why.push(format!("terms: {}", terms.join(", "))); + } + + let vocabulary = format!( + "{} {} {} {}", + m.name, + m.description.as_deref().unwrap_or(""), + m.hints.terms.join(" "), + task_titles(conn, project_hash, &m.module_id)?.join(" ") + ); + let shared: Vec = words + .intersection(&stems(&vocabulary)) + .take(5) + .cloned() + .collect(); + if !shared.is_empty() { + score += shared.len() as u32; + why.push(format!("words: {}", shared.join(", "))); + } + + if score >= MIN_SCORE { + out.push(Suggestion { + module_id: m.module_id, + name: m.name, + score, + why: why.join("; "), + }); + } + } + + out.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.module_id.cmp(&b.module_id)) + }); + out.truncate(MAX_SUGGESTIONS); + + Ok(out) +} + +/// A task with no module, with what the AI needs to place it. +#[derive(Debug, Serialize)] +pub struct Candidate { + pub task_id: String, + pub title: String, + pub status: String, + pub goal: Option, + pub outcome: Option, + pub files: Vec, + pub suggestions: Vec, +} + +const UNLINKED: &str = "FROM tasks t WHERE t.project_hash = ?1 + AND NOT EXISTS (SELECT 1 FROM task_modules tm WHERE tm.task_id = t.task_id)"; + +/// The newest `limit` tasks without a module, and how many there are in all. +pub fn backfill_candidates( + conn: &Connection, + project_hash: &str, + limit: usize, +) -> anyhow::Result<(i64, Vec)> { + let total: i64 = conn.query_row( + &format!("SELECT COUNT(*) {UNLINKED}"), + [project_hash], + |r| r.get(0), + )?; + let mut stmt = conn.prepare(&format!( + "SELECT t.task_id, t.title, t.status {UNLINKED} ORDER BY t.last_event_at DESC LIMIT ?2" + ))?; + let rows: Vec<(String, String, String)> = stmt + .query_map(rusqlite::params![project_hash, limit as i64], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })? + .collect::>()?; + + let mut out = Vec::new(); + for (task_id, title, status) in rows { + let meta = crate::db::task_metadata(conn, &task_id)?.unwrap_or_default(); + let files = crate::db::task_artifacts(conn, &task_id)?.files; + let text = format!("{title} {}", meta.goal.as_deref().unwrap_or("")); + let suggestions = suggest(conn, project_hash, &text, &files)?; + + out.push(Candidate { + task_id, + title, + status, + goal: meta.goal, + outcome: meta.outcome, + files, + suggestions, + }); + } + + Ok((total, out)) +} + #[cfg(test)] pub(crate) mod tests_support { use rusqlite::Connection; @@ -604,6 +812,144 @@ mod tests { assert!(err.contains("merged_into"), "{err}"); } + fn mapped() -> (TempDir, Connection) { + let stars = module_event( + "stars", + &ModuleFields { + name: Some("Stars".into()), + description: Some("Рейтинг звёзд и лента".into()), + hints: Some(Hints { + paths: vec!["src/stars/".into()], + terms: vec!["star feed".into()], + }), + ..Default::default() + }, + ) + .unwrap(); + let auth = module_event( + "auth", + &ModuleFields { + name: Some("Auth".into()), + description: Some("Login and token refresh".into()), + ..Default::default() + }, + ) + .unwrap(); + + journal(&[stars, auth]) + } + + #[test] + fn files_under_a_hint_path_suggest_the_module() { + let (_d, conn) = mapped(); + + let s = suggest(&conn, "p", "fix crash", &["./src/stars/feed.rs".into()]).unwrap(); + + assert_eq!(s[0].module_id, "stars"); + assert!(s[0].why.contains("src/stars/feed.rs"), "{}", s[0].why); + } + + #[test] + fn russian_word_forms_match() { + let (_d, conn) = mapped(); + + let s = suggest(&conn, "p", "Пересчитать рейтинга звёзды", &[]).unwrap(); + + assert_eq!(s.first().map(|s| s.module_id.as_str()), Some("stars")); + } + + #[test] + fn a_module_term_in_the_text_suggests_it() { + let (_d, conn) = mapped(); + + let s = suggest(&conn, "p", "The Star Feed is slow", &[]).unwrap(); + + assert_eq!(s.first().map(|s| s.module_id.as_str()), Some("stars")); + assert!(s[0].why.contains("star feed"), "{}", s[0].why); + } + + #[test] + fn one_shared_word_is_noise() { + let (_d, conn) = mapped(); + + assert!(suggest(&conn, "p", "refresh the page", &[]) + .unwrap() + .is_empty()); + } + + #[test] + fn titles_of_a_modules_tasks_join_its_vocabulary() { + let mut t = open_task("tj-a", &["auth"]); + t.meta["title"] = serde_json::json!("Rotate signing keys"); + let (_d, conn) = journal(&[named("auth", "Auth"), t]); + + let s = suggest(&conn, "p", "signing keys expire too early", &[]).unwrap(); + + assert_eq!(s.first().map(|s| s.module_id.as_str()), Some("auth")); + } + + #[test] + fn retired_modules_are_not_suggested() { + let retired = module_event( + "old", + &ModuleFields { + name: Some("Old feed".into()), + status: Some("retired".into()), + hints: Some(Hints { + paths: vec!["src/old/".into()], + terms: vec![], + }), + ..Default::default() + }, + ) + .unwrap(); + let (_d, conn) = journal(&[retired]); + + assert!(suggest(&conn, "p", "x", &["src/old/a.rs".into()]) + .unwrap() + .is_empty()); + } + + #[test] + fn backfill_lists_unlinked_tasks_with_suggestions() { + let stars = module_event( + "stars", + &ModuleFields { + name: Some("Stars".into()), + hints: Some(Hints { + paths: vec![], + terms: vec!["star feed".into()], + }), + ..Default::default() + }, + ) + .unwrap(); + let mut old = open_task("tj-old", &[]); + old.meta["goal"] = serde_json::json!("Speed up the star feed"); + + let (_d, conn) = journal(&[stars, old, open_task("tj-linked", &["stars"])]); + let (total, page) = backfill_candidates(&conn, "p", 10).unwrap(); + + assert_eq!(total, 1); + assert_eq!(page.len(), 1); + assert_eq!(page[0].task_id, "tj-old"); + assert_eq!(page[0].goal.as_deref(), Some("Speed up the star feed")); + assert_eq!(page[0].suggestions[0].module_id, "stars"); + } + + #[test] + fn backfill_pages_by_limit_but_counts_all() { + let (_d, conn) = journal(&[ + open_task("tj-1", &[]), + open_task("tj-2", &[]), + open_task("tj-3", &[]), + ]); + + let (total, page) = backfill_candidates(&conn, "p", 2).unwrap(); + + assert_eq!((total, page.len()), (3, 2)); + } + #[test] fn a_module_line_indexed_past_by_an_older_binary_is_still_projected() { // 0.30 cannot parse a `module` line: it indexes past it and writes diff --git a/crates/tj-core/src/recall.rs b/crates/tj-core/src/recall.rs index c8b96ea..abf6d65 100644 --- a/crates/tj-core/src/recall.rs +++ b/crates/tj-core/src/recall.rs @@ -31,6 +31,16 @@ const STOPWORDS: &[&str] = &[ "tool", "bash", "name", "response", ]; +/// The meaningful words of `text`: alphanumeric runs of 3+ chars, lowercased, +/// stopwords dropped. Shared by recall's FTS query and module suggestions. +pub(crate) fn keywords(text: &str) -> Vec { + 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() +} + /// 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 @@ -40,12 +50,7 @@ const STOPWORDS: &[&str] = &[ /// 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(); + let tokens = keywords(query_text); if tokens.is_empty() { return None; } From d1b4b582d42e59e3036d2a8d2edd686dc15b1a10 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 16:54:17 +0400 Subject: [PATCH 04/23] feat(core): module page and the pack's Modules line --- crates/tj-core/src/modules.rs | 377 +++++++++++++++++++++++++++++++++- crates/tj-core/src/pack.rs | 36 ++++ 2 files changed, 408 insertions(+), 5 deletions(-) diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index e060ecc..080dee2 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -246,11 +246,6 @@ fn link( )?; } - // The pack names the task's modules. - if !add.is_empty() || !remove.is_empty() { - crate::db::invalidate_pack_cascade(conn, task_id)?; - } - Ok(()) } @@ -602,6 +597,237 @@ pub fn backfill_candidates( Ok((total, out)) } +/// A module page is read like a full task pack: same budget. +const PAGE_BUDGET: usize = 32 * 1024; + +/// Entries each of decisions, rejections and constraints lists in full. +const SECTION_ITEMS: usize = 40; + +/// Closed tasks listed in full before older history shrinks to one line each. +const FULL_HISTORY: usize = 30; + +/// Longest outcome in a one-line history entry. +const SHORT_OUTCOME: usize = 80; + +struct HistoryRow { + task_id: String, + title: String, + status: String, + at: String, + outcome: Option, + note: Option, +} + +fn history( + conn: &Connection, + project_hash: &str, + ids: &[String], +) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT t.task_id, t.title, t.status, COALESCE(t.closed_at, t.last_event_at), t.outcome, + (SELECT group_concat(n.text, ' / ') FROM module_notes n + WHERE n.task_id = t.task_id AND n.module_id = tm.module_id) + FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id + WHERE tm.project_hash = ?1 AND tm.module_id = ?2", + )?; + + let mut rows = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for id in ids { + let part = stmt.query_map(rusqlite::params![project_hash, id], |r| { + Ok(HistoryRow { + task_id: r.get(0)?, + title: r.get(1)?, + status: r.get(2)?, + at: r.get(3)?, + outcome: r.get(4)?, + note: r.get(5)?, + }) + })?; + for row in part { + let row = row?; + if seen.insert(row.task_id.clone()) { + rows.push(row); + } + } + } + rows.sort_by(|a, b| b.at.cmp(&a.at)); + + Ok(rows) +} + +/// The task's live entries of one type, newest first: not corrected, not bookkeeping. +fn texts_of(conn: &Connection, task_id: &str, kind: &str) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT f.text FROM events_index ei JOIN search_fts f ON f.event_id = ei.event_id + WHERE ei.task_id = ?1 AND ei.type = ?2 AND ei.corrected_by IS NULL AND ei.bookkeeping = 0 + ORDER BY ei.timestamp DESC", + )?; + let rows = stmt + .query_map(rusqlite::params![task_id, kind], |r| r.get(0))? + .collect::>()?; + + Ok(rows) +} + +fn day(at: &str) -> &str { + at.get(..10).unwrap_or(at) +} + +fn short(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + + let cut: String = text.chars().take(max - 1).collect(); + format!("{cut}…") +} + +fn push_section(out: &mut String, title: &str, items: &[String]) { + out.push_str(&format!("\n## {title}\n")); + if items.is_empty() { + out.push_str("- (none)\n"); + } + + let shown = items.len().min(SECTION_ITEMS); + for item in &items[..shown] { + out.push_str(item); + out.push('\n'); + } + if items.len() > shown { + out.push_str(&format!("- … and {} more\n", items.len() - shown)); + } +} + +/// A module's page in Markdown: what it is, how it works now, what is +/// decided, rejected and constrained across its tasks, and its history. +pub fn page(conn: &Connection, project_hash: &str, module_id: &str) -> anyhow::Result { + let Some(m) = get(conn, project_hash, module_id)? else { + anyhow::bail!("module {module_id:?} does not exist — module_list shows the map"); + }; + + // A merged module's history lives on in the one that took it over. + let mut ids = vec![m.module_id.clone()]; + let mut merged = + conn.prepare("SELECT module_id FROM modules WHERE project_hash = ?1 AND merged_into = ?2")?; + for id in merged.query_map(rusqlite::params![project_hash, module_id], |r| { + r.get::<_, String>(0) + })? { + ids.push(id?); + } + let rows = history(conn, project_hash, &ids)?; + let (open, closed): (Vec<&HistoryRow>, Vec<&HistoryRow>) = + rows.iter().partition(|r| r.status == "open"); + + let mut out = format!("# {} ({})\n", m.name, m.module_id); + if let Some(d) = &m.description { + out.push_str(&format!("{d}\n")); + } + let merged_into = m + .merged_into + .as_deref() + .map(|i| format!(" → {i}")) + .unwrap_or_default(); + out.push_str(&format!("**Status**: {}{merged_into}\n", m.status)); + if ids.len() > 1 { + out.push_str(&format!("**Includes**: {}\n", ids[1..].join(", "))); + } + + out.push_str("\n## Now\n"); + match (&m.state, &m.state_at) { + (Some(state), Some(at)) => out.push_str(&format!("{state}\n_(as of {})_\n", day(at))), + _ => out.push_str("(no state yet — write one with module_save(state=...))\n"), + } + + // Gaps come before the long sections, so trimming a big page never drops them. + let mut gaps = Vec::new(); + if m.state.is_none() { + gaps.push("no state".to_string()); + } + let newer = closed + .iter() + .filter(|r| m.state_at.as_deref().is_some_and(|s| r.at.as_str() > s)) + .count(); + if newer > 0 { + gaps.push(format!("state is older than {newer} closed task(s)")); + } + let no_outcome = closed.iter().filter(|r| r.outcome.is_none()).count(); + if no_outcome > 0 { + gaps.push(format!("{no_outcome} closed task(s) without an outcome")); + } + if !gaps.is_empty() { + out.push_str(&format!("\n## Gaps\n- {}\n", gaps.join("\n- "))); + } + + let (mut decisions, mut rejected, mut constraints) = (Vec::new(), Vec::new(), Vec::new()); + for r in &rows { + for d in crate::pack::active_decisions(conn, &r.task_id)? { + decisions.push(format!("- {}{} ({})", d.text, d.marker(), r.task_id)); + } + for x in crate::pack::rejections(conn, &r.task_id)? { + rejected.push(format!("- {}{} ({})", x.text, x.marker(), r.task_id)); + } + for c in texts_of(conn, &r.task_id, "constraint")? { + constraints.push(format!("- {c} ({})", r.task_id)); + } + } + push_section(&mut out, "Active decisions", &decisions); + push_section(&mut out, "Rejected", &rejected); + push_section(&mut out, "Constraints", &constraints); + + let open: Vec = open + .iter() + .map(|r| format!("- {} {}", r.task_id, r.title)) + .collect(); + push_section(&mut out, "Open tasks", &open); + + out.push_str("\n## History\n"); + if closed.is_empty() { + out.push_str("- (none)\n"); + } + for (i, r) in closed.iter().enumerate() { + let outcome = r.outcome.as_deref().unwrap_or("(no outcome)"); + if i < FULL_HISTORY { + out.push_str(&format!( + "- {} · {} · {} — {outcome}\n", + day(&r.at), + r.task_id, + r.title + )); + if let Some(note) = &r.note { + out.push_str(&format!(" ↳ {note}\n")); + } + } else { + out.push_str(&format!( + "- {} · {} · {}\n", + day(&r.at), + r.task_id, + short(outcome, SHORT_OUTCOME) + )); + } + } + + Ok(fit(out)) +} + +/// Keep the page within budget by cutting the oldest history at a line end. +fn fit(mut page: String) -> String { + if page.len() <= PAGE_BUDGET { + return page; + } + + let marker = "- … older history cut to fit — task packs have the rest\n"; + let mut cut = PAGE_BUDGET - marker.len(); + while !page.is_char_boundary(cut) { + cut -= 1; + } + let cut = page[..cut].rfind('\n').map_or(cut, |i| i + 1); + page.truncate(cut); + page.push_str(marker); + + page +} + #[cfg(test)] pub(crate) mod tests_support { use rusqlite::Connection; @@ -950,6 +1176,147 @@ mod tests { assert_eq!((total, page.len()), (3, 2)); } + fn said(task: &str, kind: EventType, text: &str) -> Event { + Event::new(task, kind, Author::Agent, Source::Chat, text.into()) + } + + #[test] + fn page_shows_state_decisions_rejections_and_history() { + let stars = module_event( + "stars", + &ModuleFields { + name: Some("Stars".into()), + description: Some("Star ratings".into()), + state: Some("Ranks by score".into()), + ..Default::default() + }, + ) + .unwrap(); + let close = close_task( + "tj-a", + serde_json::json!({ + "outcome": "Shipped ranking", + "module_notes": notes_meta(&[("stars".into(), "Ranking added".into())]) + }), + ); + + let (_d, conn) = journal(&[ + stars, + open_task("tj-a", &["stars"]), + said("tj-a", EventType::Decision, "Use Wilson score"), + said("tj-a", EventType::Rejection, "Plain average"), + said("tj-a", EventType::Constraint, "Votes are public"), + close, + open_task("tj-b", &["stars"]), + ]); + let p = page(&conn, "p", "stars").unwrap(); + + for needle in [ + "# Stars (stars)", + "Star ratings", + "## Now", + "Ranks by score", + "## Active decisions", + "Use Wilson score", + "## Rejected", + "Plain average", + "## Constraints", + "Votes are public", + "## Open tasks", + "tj-b", + "## History", + "tj-a", + "Shipped ranking", + "Ranking added", + ] { + assert!(p.contains(needle), "missing {needle}:\n{p}"); + } + } + + #[test] + fn page_leaves_out_corrected_entries() { + let wrong = said("tj-a", EventType::Decision, "Use plain average"); + let mut fix = said("tj-a", EventType::Correction, "No: Wilson score"); + fix.corrects = Some(wrong.event_id.clone()); + + let (_d, conn) = journal(&[ + named("stars", "Stars"), + open_task("tj-a", &["stars"]), + wrong, + fix, + ]); + let p = page(&conn, "p", "stars").unwrap(); + + assert!(!p.contains("Use plain average"), "{p}"); + } + + #[test] + fn page_names_the_gaps_of_a_module() { + let (_d, conn) = journal(&[ + named("stars", "Stars"), + open_task("tj-a", &["stars"]), + close_task("tj-a", serde_json::json!({})), + ]); + let p = page(&conn, "p", "stars").unwrap(); + + assert!(p.contains("## Gaps"), "{p}"); + assert!(p.contains("no state"), "{p}"); + assert!(p.contains("without an outcome"), "{p}"); + } + + #[test] + fn a_merged_modules_history_shows_on_the_one_that_took_it_over() { + let old = module_event( + "old", + &ModuleFields { + name: Some("Old".into()), + status: Some("merged".into()), + merged_into: Some("stars".into()), + ..Default::default() + }, + ) + .unwrap(); + let (_d, conn) = journal(&[ + named("stars", "Stars"), + named("old", "Old"), + open_task("tj-old", &["old"]), + old, + ]); + let p = page(&conn, "p", "stars").unwrap(); + + assert!(p.contains("tj-old"), "{p}"); + } + + #[test] + fn page_of_a_huge_module_fits_the_budget() { + let mut events = vec![named("big", "Big")]; + for i in 0..400 { + let id = format!("tj-{i:04}"); + events.push(open_task(&id, &["big"])); + events.push(said(&id, EventType::Decision, &"d".repeat(200))); + events.push(close_task( + &id, + serde_json::json!({"outcome": "o".repeat(150)}), + )); + } + let (_d, conn) = journal(&events); + + let p = page(&conn, "p", "big").unwrap(); + + assert!(p.len() <= PAGE_BUDGET, "{}", p.len()); + assert!(p.contains("## History"), "history cut away"); + assert!(p.contains("more"), "no sign that entries were left out"); + } + + #[test] + fn unknown_module_page_is_an_error() { + let (_d, conn) = mapped(); + + let err = page(&conn, "p", "nope").unwrap_err().to_string(); + + assert!(err.contains("module_list"), "{err}"); + } + #[test] fn a_module_line_indexed_past_by_an_older_binary_is_still_projected() { // 0.30 cannot parse a `module` line: it indexes past it and writes diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 4759c76..2a66288 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -428,6 +428,16 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res } } + // v0.31.0: the modules of the project chronicle this task belongs to. + let modules = crate::modules::modules_of_task(conn, task_id)?; + if !modules.is_empty() { + let names: Vec = modules + .iter() + .map(|(id, name)| format!("{name} ({id})")) + .collect(); + text.push_str(&format!("**Modules**: {}\n", names.join(", "))); + } + // v0.5.0 Phase B: artifacts auto-extracted from event text. Render // only categories that have entries — empty groups are noise on a // 30-event task. Order is stable so packs diff cleanly. @@ -1222,6 +1232,32 @@ mod tests { ); } + #[test] + fn pack_names_the_tasks_modules() { + use crate::modules::tests_support::{journal, open_task}; + use crate::modules::{module_event, ModuleFields}; + + let stars = module_event( + "stars", + &ModuleFields { + name: Some("Stars".into()), + ..Default::default() + }, + ) + .unwrap(); + let (_d, conn) = journal(&[stars, open_task("tj-a", &["stars"]), open_task("tj-b", &[])]); + + let linked = assemble(&conn, "tj-a", PackMode::Compact).unwrap(); + let unlinked = assemble(&conn, "tj-b", PackMode::Compact).unwrap(); + + assert!( + linked.text.contains("**Modules**: Stars (stars)"), + "{}", + linked.text + ); + assert!(!unlinked.text.contains("**Modules**"), "{}", unlinked.text); + } + #[test] fn assemble_includes_lifecycle_history() { use crate::db; From 811b73d50d49b5f506389d4c1bcafd3c6c415eb8 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 16:55:44 +0400 Subject: [PATCH 05/23] =?UTF-8?q?feat(core):=20archive=20gaps=20=E2=80=94?= =?UTF-8?q?=20what=20the=20chronicle=20is=20missing=20and=20what=20to=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tj-cli/src/main.rs | 1 + crates/tj-core/src/archive.rs | 233 ++++++++++++++++++++++++++++ crates/tj-core/src/lib.rs | 1 + crates/tj-core/src/session_state.rs | 41 +++++ 4 files changed, 276 insertions(+) create mode 100644 crates/tj-core/src/archive.rs diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index b7c6474..193f8bb 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1789,6 +1789,7 @@ fn real_main() -> Result<()> { session_id: session, open_tasks: 0, active: None, + archive: Vec::new(), } }; diff --git a/crates/tj-core/src/archive.rs b/crates/tj-core/src/archive.rs new file mode 100644 index 0000000..5764506 --- /dev/null +++ b/crates/tj-core/src/archive.rs @@ -0,0 +1,233 @@ +//! What the project chronicle is missing, most important first, with the +//! next step for the AI. Computed from the derived state on every read, so +//! there is nothing to keep in sync. + +use rusqlite::Connection; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Gap { + /// Tasks exist, but no module does. + NoMap { tasks: i64 }, + /// Tasks that belong to no module. + UnlinkedTasks { count: i64 }, + /// Tasks closed in a module since its state was last written. + StaleModule { + module_id: String, + closed_since: i64, + }, + /// The session's active task belongs to no module. + TaskWithoutModule { task_id: String }, +} + +impl Gap { + /// One line: what is missing and what to do about it. + pub fn action(&self) -> String { + match self { + Gap::NoMap { tasks } => format!( + "no module map yet ({tasks} tasks) — map the project into modules and confirm them \ + with the user (Claude Code: /task-journal:map; otherwise the task-journal skill, \ + section Chronicle)" + ), + Gap::UnlinkedTasks { count } => format!( + "{count} task(s) belong to no module — sort them with module_backfill_candidates, \ + confirm with the user, then module_link" + ), + Gap::StaleModule { + module_id, + closed_since, + } => format!( + "module {module_id}: {closed_since} closed task(s) not reflected in its state — \ + read module_page({module_id}) and rewrite it with module_save(state=...)" + ), + Gap::TaskWithoutModule { task_id } => format!( + "the active task {task_id} belongs to no module — link it with module_link \ + (module_list shows the map)" + ), + } + } +} + +/// The line a session start or a tool reply shows: the most important gap. +pub fn headline(gaps: &[Gap]) -> Option { + let first = gaps.first()?; + let more = match gaps.len() { + 1 => String::new(), + n => format!(" (+{} more)", n - 1), + }; + + Some(format!("📚 Chronicle: {}{more}", first.action())) +} + +/// What the chronicle of `project_hash` is missing, most important first. +/// `active_task` is the session's task, checked for a module of its own. +pub fn gaps( + conn: &Connection, + project_hash: &str, + active_task: Option<&str>, +) -> anyhow::Result> { + let count = |sql: &str| -> anyhow::Result { + Ok(conn.query_row(sql, [project_hash], |r| r.get(0))?) + }; + + let tasks = count("SELECT COUNT(*) FROM tasks WHERE project_hash = ?1")?; + if tasks == 0 { + return Ok(Vec::new()); + } + if count("SELECT COUNT(*) FROM modules WHERE project_hash = ?1")? == 0 { + return Ok(vec![Gap::NoMap { tasks }]); + } + + let mut out = Vec::new(); + + let unlinked = count( + "SELECT COUNT(*) FROM tasks t WHERE t.project_hash = ?1 + AND NOT EXISTS (SELECT 1 FROM task_modules tm WHERE tm.task_id = t.task_id)", + )?; + if unlinked > 0 { + out.push(Gap::UnlinkedTasks { count: unlinked }); + } + + let mut stmt = conn.prepare( + "SELECT m.module_id, COUNT(t.task_id) FROM modules m + JOIN task_modules tm ON tm.project_hash = m.project_hash AND tm.module_id = m.module_id + JOIN tasks t ON t.task_id = tm.task_id AND t.status = 'closed' + WHERE m.project_hash = ?1 AND m.status = 'active' + AND (m.state_at IS NULL OR COALESCE(t.closed_at, t.last_event_at) > m.state_at) + GROUP BY m.module_id + ORDER BY COUNT(t.task_id) DESC, m.module_id", + )?; + for row in stmt.query_map([project_hash], |r| Ok((r.get(0)?, r.get(1)?)))? { + let (module_id, closed_since) = row?; + out.push(Gap::StaleModule { + module_id, + closed_since, + }); + } + + if let Some(task_id) = active_task { + if crate::modules::modules_of_task(conn, task_id)?.is_empty() { + out.push(Gap::TaskWithoutModule { + task_id: task_id.to_string(), + }); + } + } + + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::tests_support::{close_task, journal, open_task}; + use crate::modules::{module_event, ModuleFields}; + + fn stars(state: Option<&str>) -> crate::event::Event { + module_event( + "stars", + &ModuleFields { + name: Some("Stars".into()), + state: state.map(str::to_string), + ..Default::default() + }, + ) + .unwrap() + } + + #[test] + fn an_empty_journal_has_no_gaps() { + let (_d, conn) = journal(&[]); + + assert!(gaps(&conn, "p", None).unwrap().is_empty()); + } + + #[test] + fn tasks_without_any_module_is_no_map() { + let (_d, conn) = journal(&[open_task("tj-a", &[]), open_task("tj-b", &[])]); + + assert_eq!( + gaps(&conn, "p", None).unwrap(), + vec![Gap::NoMap { tasks: 2 }] + ); + } + + #[test] + fn a_mapped_project_reports_unlinked_stale_and_the_active_task() { + let mut close = close_task("tj-a", serde_json::json!({})); + // Closed after the module's state was written. + close.timestamp = "2999-01-01T00:00:00.000Z".into(); + + let (_d, conn) = journal(&[ + stars(Some("v1")), + open_task("tj-a", &["stars"]), + close, + open_task("tj-b", &[]), + ]); + let g = gaps(&conn, "p", Some("tj-b")).unwrap(); + + assert_eq!( + g, + vec![ + Gap::UnlinkedTasks { count: 1 }, + Gap::StaleModule { + module_id: "stars".into(), + closed_since: 1 + }, + Gap::TaskWithoutModule { + task_id: "tj-b".into() + }, + ] + ); + } + + #[test] + fn a_module_with_a_fresh_state_is_not_stale() { + let (_d, conn) = journal(&[ + open_task("tj-a", &[]), + close_task("tj-a", serde_json::json!({})), + stars(Some("written after the task closed")), + crate::modules::link_event("tj-a", &["stars".into()], &[]), + ]); + + assert!(gaps(&conn, "p", None).unwrap().is_empty()); + } + + #[test] + fn closed_tasks_in_a_module_without_state_are_stale() { + let (_d, conn) = journal(&[ + stars(None), + open_task("tj-a", &["stars"]), + close_task("tj-a", serde_json::json!({})), + ]); + + assert_eq!( + gaps(&conn, "p", None).unwrap(), + vec![Gap::StaleModule { + module_id: "stars".into(), + closed_since: 1 + }] + ); + } + + #[test] + fn the_headline_names_the_first_gap_and_counts_the_rest() { + let g = vec![Gap::NoMap { tasks: 3 }, Gap::UnlinkedTasks { count: 2 }]; + + let line = headline(&g).unwrap(); + + assert!( + line.starts_with("📚 Chronicle: no module map yet (3 tasks)"), + "{line}" + ); + assert!(line.ends_with("(+1 more)"), "{line}"); + assert!(headline(&[]).is_none()); + } + + #[test] + fn gaps_serialize_with_a_kind_tag() { + let json = serde_json::to_string(&Gap::UnlinkedTasks { count: 2 }).unwrap(); + + assert_eq!(json, r#"{"kind":"unlinked_tasks","count":2}"#); + } +} diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index d34e513..a72ce2c 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -57,6 +57,7 @@ mod task_id_tests { } } +pub mod archive; pub mod artifacts; pub mod classifier; pub mod completeness; diff --git a/crates/tj-core/src/session_state.rs b/crates/tj-core/src/session_state.rs index d3c4b6c..0f37c7a 100644 --- a/crates/tj-core/src/session_state.rs +++ b/crates/tj-core/src/session_state.rs @@ -24,6 +24,8 @@ pub struct SessionState { pub session_id: Option, pub open_tasks: i64, pub active: Option, + /// What the project chronicle is missing, most important first. + pub archive: Vec, } #[derive(Debug, Serialize)] @@ -35,6 +37,8 @@ pub struct ActiveTask { pub counts: BTreeMap, /// The latest entries, oldest first. No `open` / `amend` bookkeeping. pub recent: Vec, + /// Names of the modules the task belongs to. + pub modules: Vec, } #[derive(Debug, Serialize)] @@ -77,12 +81,18 @@ pub fn session_state( (None, None) => None, }; let active = active_id.map(|id| active_task(conn, &id)).transpose()?; + let archive = crate::archive::gaps( + conn, + project_hash, + active.as_ref().map(|a| a.task_id.as_str()), + )?; Ok(SessionState { schema: SCHEMA, session_id: session_id.map(str::to_string), open_tasks, active, + archive, }) } @@ -131,6 +141,10 @@ fn active_task(conn: &Connection, task_id: &str) -> anyhow::Result { goal: goal.filter(|g| !g.trim().is_empty()), counts, recent, + modules: crate::modules::modules_of_task(conn, task_id)? + .into_iter() + .map(|(_, name)| name) + .collect(), }) } @@ -158,6 +172,33 @@ mod tests { (dir, conn) } + #[test] + fn state_carries_the_tasks_modules_and_the_archive_gaps() { + use crate::modules::tests_support::{journal, open_task}; + + let stars = crate::modules::module_event( + "stars", + &crate::modules::ModuleFields { + name: Some("Stars".into()), + ..Default::default() + }, + ) + .unwrap(); + let mut mine = open_task("tj-a", &["stars"]); + crate::session_id::stamp_session_id(&mut mine.meta, Some("s1")); + let (_d, conn) = journal(&[stars, mine, open_task("tj-b", &[])]); + + let state = session_state(&conn, "p", Some("s1"), None).unwrap(); + + assert_eq!( + state.active.as_ref().unwrap().modules, + vec!["Stars".to_string()] + ); + let json = serde_json::to_value(&state).unwrap(); + assert_eq!(json["archive"][0]["kind"], "unlinked_tasks"); + assert_eq!(json["active"]["modules"][0], "Stars"); + } + #[test] fn active_task_is_the_sessions_own_not_the_newest() { let (_dir, conn) = conn(); From 3cb7ad5c23891ddefdb984de4418eb453b8bac57 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 17:01:09 +0400 Subject: [PATCH 06/23] feat(mcp): chronicle tools and module fields on create, close and search --- crates/tj-core/src/archive.rs | 2 +- crates/tj-core/src/modules.rs | 6 +- crates/tj-mcp/src/main.rs | 817 +++++++++++++++++++++++++++++++++- 3 files changed, 808 insertions(+), 17 deletions(-) diff --git a/crates/tj-core/src/archive.rs b/crates/tj-core/src/archive.rs index 5764506..b67852f 100644 --- a/crates/tj-core/src/archive.rs +++ b/crates/tj-core/src/archive.rs @@ -5,7 +5,7 @@ use rusqlite::Connection; use serde::Serialize; -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Gap { /// Tasks exist, but no module does. diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index 080dee2..2058bca 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -35,7 +35,7 @@ pub struct ModuleFields { pub merged_into: Option, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub struct Module { pub module_id: String, pub name: String, @@ -417,7 +417,7 @@ const RU_STOPWORDS: &[&str] = &[ ]; /// A module the journal thinks a task belongs to. Only a hint: the AI decides. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, JsonSchema)] pub struct Suggestion { pub module_id: String, pub name: String, @@ -542,7 +542,7 @@ pub fn suggest( } /// A task with no module, with what the AI needs to place it. -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, JsonSchema)] pub struct Candidate { pub task_id: String, pub title: String, diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index f5bbb61..9a422b2 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -145,6 +145,8 @@ THE RITUAL — do this on EVERY coding session, not optional: every decision, rejection, and key finding from this session?" If not, log them NOW. 6. DONE → task_close(reason, outcome, outcome_tag). +7. MODULES: task_create(modules=[..]), read module_page first; + task_close(module_notes=[..]). "📚 Chronicle: …" names a gap: fix it after. Record in the user's language, terse and specific (file:line, ids, names). One task = one objective — don't spawn a new task per turn; events accumulate under @@ -198,6 +200,8 @@ pub struct TaskSearchParams { /// (`decision`, `evidence`, `finding`, `rejection`, ...). /// Accepts any value in [`tj_core::event::EventType::ALL`]. pub event_type: Option, + /// Only tasks linked to this module. + pub module: Option, } #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct TaskSearchResult { @@ -241,11 +245,32 @@ pub struct TaskCreateParams { pub parent: Option, /// Filled in by the client or the Task Journal Claude Code mod; leave it out. pub session_id: Option, + /// Modules this task belongs to (ids from module_list). An unknown id is + /// an error; a merged module links to the one that took it over. + pub modules: Option>, } -#[derive(Debug, Serialize, schemars::JsonSchema)] +#[derive(Debug, Default, Serialize, schemars::JsonSchema)] pub struct TaskCreateResult { pub task_id: String, pub title: String, + /// The task's modules with their current state: read module_page before changing them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modules: Vec, + /// Modules that look like a fit when none were given: link with module_link. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub suggested_modules: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, + /// The chronicle's most important gap, to close once the current work is done. + #[serde(skip_serializing_if = "Option::is_none")] + pub chronicle: Option, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModuleBrief { + pub module_id: String, + pub name: String, + pub state: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -298,6 +323,14 @@ pub struct TaskCloseParams { pub outcome_tag: Option, /// Filled in by the client or the Task Journal Claude Code mod; leave it out. pub session_id: Option, + /// One line per module on what this task changed there: the module's history. + pub module_notes: Option>, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ModuleNote { + pub module: String, + pub text: String, } #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct TaskCloseResult { @@ -313,6 +346,11 @@ pub struct TaskCloseResult { /// has no detected gaps; omitted from the wire shape in that case. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub completeness_gaps: Vec, + /// What the task's modules still need from you now that it is closed. + #[serde(skip_serializing_if = "Option::is_none")] + pub module_reminder: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -329,6 +367,78 @@ pub struct MemoryNoteResult { pub text: String, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ModuleSaveParams { + /// Short slug: lowercase latin letters, digits, dashes (`stars`, `auth-refresh`). + pub module_id: String, + /// Human name. Required for a new module. + pub name: Option, + /// One to three sentences: what this part of the system is. + pub description: Option, + /// Code path prefixes and terms that mark the module; they drive suggestions. + pub hints: Option, + /// How the module works now: a short living text you rewrite when it changes. + pub state: Option, + /// `active`, `retired` or `merged` (with `merged_into`). + pub status: Option, + pub merged_into: Option, +} +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModuleSaveResult { + pub module_id: String, + pub created: bool, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ModuleLink { + pub task_id: String, + #[serde(default)] + pub add: Vec, + #[serde(default)] + pub remove: Vec, +} +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ModuleLinkParams { + pub links: Vec, +} +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModuleLinkResult { + /// Tasks whose links changed. + pub linked: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModuleListResult { + pub modules: Vec, + /// What the chronicle is missing, most important first. + pub gaps: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub chronicle: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ModulePageParams { + pub module_id: String, +} +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModulePageResult { + pub module_id: String, + pub text: String, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct BackfillParams { + /// Tasks per page, newest first (default 20, at most 100). + pub limit: Option, +} +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct BackfillResult { + pub total_unlinked: i64, + pub candidates: Vec, +} + fn parse_event_type(s: &str) -> anyhow::Result { use tj_core::event::EventType::*; Ok(match s { @@ -400,6 +510,101 @@ fn require_task( Ok(()) } +/// Run `f` on this project's state with the journal tail ingested first. +fn with_state(f: impl FnOnce(&Connection, &str) -> anyhow::Result) -> anyhow::Result { + let (project_hash, events_path, state_path) = project_paths()?; + 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)?; + + f(&conn, &project_hash) +} + +/// Append `events` to this project's journal, durably. +fn append_events(events: &[tj_core::event::Event]) -> anyhow::Result<()> { + let (_, events_path, _) = project_paths()?; + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + for event in events { + writer.append(event)?; + } + writer.flush_durable()?; + + Ok(()) +} + +/// Whether this project has a journal yet; read-only tools create nothing without one. +fn has_journal() -> anyhow::Result { + Ok(project_paths()?.1.exists()) +} + +/// What a new task's reply says about the chronicle: its modules with their +/// state, or modules that look like a fit, and the archive's top gap. +fn new_task_chronicle( + task_id: &str, + text: &str, + linked: &[String], +) -> anyhow::Result<( + Vec, + Vec, + Option, +)> { + with_state(|conn, project_hash| { + let mut modules = Vec::new(); + for id in linked { + if let Some(m) = tj_core::modules::get(conn, project_hash, id)? { + modules.push(ModuleBrief { + module_id: m.module_id, + name: m.name, + state: m.state, + }); + } + } + let suggested = if linked.is_empty() { + tj_core::modules::suggest(conn, project_hash, text, &[])? + } else { + Vec::new() + }; + let gaps = tj_core::archive::gaps(conn, project_hash, Some(task_id))?; + + Ok((modules, suggested, tj_core::archive::headline(&gaps))) + }) +} + +/// What a closed task's modules still need: a link, or a fresh state. +fn module_reminder( + conn: &Connection, + project_hash: &str, + task_id: &str, + has_notes: bool, +) -> anyhow::Result> { + // No map yet: the session start and module_list already say so. + if tj_core::modules::list(conn, project_hash)?.is_empty() { + return Ok(None); + } + + let linked = tj_core::modules::modules_of_task(conn, task_id)?; + let ids = linked + .iter() + .map(|(id, _)| id.as_str()) + .collect::>() + .join(", "); + + Ok(Some(match (linked.is_empty(), has_notes) { + (true, _) => "This task belongs to no module — link it with module_link so its history \ + is kept (module_list shows the map)." + .to_string(), + (false, false) => format!( + "Closed without module_notes, so the history of {ids} has no line on it — make sure \ + each module's state says what changed: module_save(state=...)." + ), + (false, true) => { + format!("If {ids} work differently now, rewrite the state with module_save(state=...).") + } + })) +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct TaskCheckParams { pub task_id: String, @@ -522,7 +727,7 @@ impl TaskJournalServer { #[tool( name = "task_search", - description = "Search tasks. `query` is full-text over event text; empty or absent lists the project's tasks, newest first. `status`: open | closed | any (default). `project`: absolute dir of another project. `tasks` gives id, title, status, last_event_at, goal per hit." + description = "Search tasks. `query` is full-text over event text; empty or absent lists the project's tasks, newest first. `status`: open | closed | any (default). `module`: only tasks of that module. `project`: absolute dir of another project. `tasks` gives id, title, status, last_event_at, goal per hit." )] async fn task_search( &self, @@ -532,6 +737,7 @@ impl TaskJournalServer { let query = p.query.clone(); let raw_query = p.query.clone(); let event_type = p.event_type.clone(); + let module = p.module.clone(); let tasks = run_blocking(move || { let status = match p.status.as_deref() { None | Some("any") => None, @@ -567,10 +773,15 @@ impl TaskJournalServer { WHERE (?1 IS NULL OR status = ?1) \ AND (?2 IS NULL OR task_id IN \ (SELECT task_id FROM events_index WHERE type = ?2)) \ + AND (?3 IS NULL OR task_id IN \ + (SELECT task_id FROM task_modules WHERE module_id = ?3)) \ ORDER BY last_event_at DESC, rowid DESC LIMIT 50", )?; let hits = stmt - .query_map(rusqlite::params![status, event_type], task_search_hit)? + .query_map( + rusqlite::params![status, event_type, module], + task_search_hit, + )? .collect::>()?; return Ok(hits); @@ -587,11 +798,13 @@ impl TaskJournalServer { FROM search_fts JOIN tasks t ON t.task_id = search_fts.task_id \ WHERE search_fts MATCH ?1 \ AND (?2 IS NULL OR search_fts.type = ?2) \ - AND (?3 IS NULL OR t.status = ?3) LIMIT 50", + AND (?3 IS NULL OR t.status = ?3) \ + AND (?4 IS NULL OR t.task_id IN \ + (SELECT task_id FROM task_modules WHERE module_id = ?4)) LIMIT 50", )?; let mut hits: Vec = stmt .query_map( - rusqlite::params![fts_query, event_type, status], + rusqlite::params![fts_query, event_type, status, module], task_search_hit, )? .collect::>()?; @@ -608,10 +821,15 @@ impl TaskJournalServer { FROM search_fts JOIN tasks t ON t.task_id = search_fts.task_id \ WHERE search_fts.text LIKE ?1 \ AND (?2 IS NULL OR search_fts.type = ?2) \ - AND (?3 IS NULL OR t.status = ?3) LIMIT 50", + AND (?3 IS NULL OR t.status = ?3) \ + AND (?4 IS NULL OR t.task_id IN \ + (SELECT task_id FROM task_modules WHERE module_id = ?4)) LIMIT 50", )?; hits = stmt_like - .query_map(rusqlite::params![like, event_type, status], task_search_hit)? + .query_map( + rusqlite::params![like, event_type, status, module], + task_search_hit, + )? .collect::>()?; } @@ -631,7 +849,7 @@ impl TaskJournalServer { #[tool( name = "task_create", - description = "Open a new task. Always pass `goal` (one sentence: what the user is trying to accomplish) — it is the first line of every resume pack and the anchor for \"why was this done?\" weeks later. `title` is a short label; `initial_context` is optional." + description = "Open a new task. Always pass `goal` (one sentence: what the user is trying to accomplish) — it is the first line of every resume pack and the anchor for \"why was this done?\" weeks later. `title` is a short label; `initial_context` is optional. `modules`: the modules it belongs to (module_list); without them the reply suggests some." )] async fn task_create( &self, @@ -663,6 +881,7 @@ impl TaskJournalServer { return Ok(TaskCreateResult { task_id: existing, title: p.title.clone(), + ..Default::default() }); } } @@ -684,6 +903,15 @@ impl TaskJournalServer { } } + // Modules are checked before the open event is written: an + // unknown id fails the call and leaves no task behind. + let resolved = match p.modules.as_deref() { + Some(ids) if !ids.is_empty() => Some(with_state(|conn, hash| { + tj_core::modules::resolve(conn, hash, ids) + })?), + _ => None, + }; + let mut event = tj_core::event::Event::new( task_id.clone(), tj_core::event::EventType::Open, @@ -704,6 +932,9 @@ impl TaskJournalServer { if let Some(ref r) = loom_ref { event.meta["external"] = serde_json::json!([r]); } + if let Some(r) = &resolved { + event.meta["modules"] = serde_json::json!(r.ids); + } tj_core::session_id::stamp_session_id( &mut event.meta, session_id_or_env(p.session_id.as_deref()).as_deref(), @@ -713,9 +944,24 @@ impl TaskJournalServer { writer.append(&event)?; writer.flush_durable()?; + // The task is written; what follows only advises, so it never fails the call. + let linked = resolved.as_ref().map(|r| r.ids.clone()).unwrap_or_default(); + let text = format!( + "{} {} {}", + p.title, + p.goal.as_deref().unwrap_or(""), + p.initial_context.as_deref().unwrap_or("") + ); + let (modules, suggested_modules, chronicle) = + new_task_chronicle(&task_id, &text, &linked).unwrap_or_default(); + Ok(TaskCreateResult { task_id, title: p.title.clone(), + modules, + suggested_modules, + warnings: resolved.map(|r| r.warnings).unwrap_or_default(), + chronicle, }) }) .await @@ -828,7 +1074,7 @@ impl TaskJournalServer { #[tool( name = "task_close", - description = "Close a task with reason and outcome." + description = "Close a task with reason and outcome. `module_notes`: one line per module on what this task changed there — it becomes the module's history." )] async fn task_close( &self, @@ -836,11 +1082,13 @@ impl TaskJournalServer { ) -> Result, McpError> { traced_tool("task_close", async move { let task_id = p.task_id.clone(); - let (open_kids, gaps) = run_blocking(move || { + let (open_kids, gaps, module_reminder, warnings) = run_blocking(move || { let (project_hash, events_path, state_path) = project_paths()?; let conn_arc = cached_open(&state_path)?; let open_kids; + let mut notes = Vec::new(); + let mut warnings = Vec::new(); { let conn = conn_arc .lock() @@ -865,6 +1113,17 @@ impl TaskJournalServer { } } open_kids = tj_core::db::count_open_children(&conn, &p.task_id)?; + + // A note to an unknown module fails the close before it is written. + for note in p.module_notes.iter().flatten() { + let r = tj_core::modules::resolve( + &conn, + &project_hash, + std::slice::from_ref(¬e.module), + )?; + warnings.extend(r.warnings); + notes.push((r.ids[0].clone(), note.text.clone())); + } } // release the connection lock before doing the JSONL append let mut event = tj_core::event::Event::new( @@ -882,6 +1141,12 @@ impl TaskJournalServer { if let Some(t) = &p.outcome_tag { meta.insert("outcome_tag".into(), serde_json::Value::String(t.clone())); } + if !notes.is_empty() { + meta.insert( + "module_notes".into(), + tj_core::modules::notes_meta(¬es), + ); + } // Layer-2 close harvest: stamp deterministic git/gh refs // (commit, branch, PR) into the close event so the resume pack // reads as a clickable ledger of what shipped. Best-effort and @@ -909,6 +1174,7 @@ impl TaskJournalServer { // assess. Any error here must NOT fail the close — handle // locally, never `?`-propagate. let mut gaps: Vec = Vec::new(); + let mut module_reminder = None; 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( @@ -918,8 +1184,12 @@ impl TaskJournalServer { ) { gaps = report.gaps.into_iter().map(|g| g.detail).collect(); } + module_reminder = + crate::module_reminder(&conn, &project_hash, &p.task_id, !notes.is_empty()) + .ok() + .flatten(); } - Ok((open_kids, gaps)) + Ok((open_kids, gaps, module_reminder, warnings)) }) .await?; let note = if open_kids > 0 { @@ -932,11 +1202,200 @@ impl TaskJournalServer { closed: true, note, completeness_gaps: gaps, + module_reminder, + warnings, })) }) .await } + #[tool( + name = "module_list", + description = "The project's module map — parts of the system by meaning, each with its task count and last activity — and what the chronicle is missing (`gaps`, most important first)." + )] + async fn module_list(&self) -> Result, McpError> { + traced_tool("module_list", async move { + run_blocking(move || { + if !has_journal()? { + return Ok(Json(ModuleListResult { + modules: Vec::new(), + gaps: Vec::new(), + chronicle: None, + })); + } + + with_state(|conn, project_hash| { + let gaps = tj_core::archive::gaps(conn, project_hash, None)?; + + Ok(Json(ModuleListResult { + modules: tj_core::modules::list(conn, project_hash)?, + chronicle: tj_core::archive::headline(&gaps), + gaps, + })) + }) + }) + .await + }) + .await + } + + #[tool( + name = "module_page", + description = "A module's page: what it is, how it works now, the active decisions, rejections and constraints of all its tasks, and its history. Read it before you change the module." + )] + async fn module_page( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("module_page", async move { + run_blocking(move || { + if !has_journal()? { + anyhow::bail!( + "module {:?} does not exist — module_list shows the map", + p.module_id + ); + } + + let text = with_state(|conn, project_hash| { + tj_core::modules::page(conn, project_hash, &p.module_id) + })?; + + Ok(Json(ModulePageResult { + module_id: p.module_id, + text, + })) + }) + .await + }) + .await + } + + #[tool( + name = "module_save", + description = "Create or update a module: a part of the system by meaning (not a folder, not a ticket). Pass only the fields that change; a new module needs `name`. `state` is the short living text of how the module works now — rewrite it when a task changes the module. `hints` (paths, terms) drive module suggestions." + )] + async fn module_save( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("module_save", async move { + run_blocking(move || { + let fields = tj_core::modules::ModuleFields { + name: p.name, + description: p.description, + hints: p.hints, + state: p.state, + status: p.status, + merged_into: p.merged_into, + }; + let event = tj_core::modules::module_event(&p.module_id, &fields)?; + + let created = with_state(|conn, project_hash| { + let exists = tj_core::modules::get(conn, project_hash, &p.module_id)?.is_some(); + if !exists && fields.name.is_none() { + anyhow::bail!("module {} is new: pass `name` too", p.module_id); + } + if let Some(into) = &fields.merged_into { + if into == &p.module_id { + anyhow::bail!("a module cannot be merged into itself"); + } + if tj_core::modules::get(conn, project_hash, into)?.is_none() { + anyhow::bail!("merged_into: module {into:?} does not exist"); + } + } + + Ok(!exists) + })?; + append_events(&[event])?; + + Ok(Json(ModuleSaveResult { + module_id: p.module_id, + created, + })) + }) + .await + }) + .await + } + + #[tool( + name = "module_link", + description = "Link tasks to modules or unlink them, many at once: `links` = [{task_id, add: [module ids], remove: [module ids]}]. Unknown tasks or modules fail the whole call before anything is written; a merged module links to the one that took it over." + )] + async fn module_link( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("module_link", async move { + run_blocking(move || { + let mut warnings = Vec::new(); + let events = with_state(|conn, project_hash| { + let mut events = Vec::new(); + for link in &p.links { + if !tj_core::db::task_exists(conn, &link.task_id)? { + anyhow::bail!("task not found: {}", link.task_id); + } + for id in &link.remove { + tj_core::modules::validate_id(id)?; + } + let add = tj_core::modules::resolve(conn, project_hash, &link.add)?; + warnings.extend(add.warnings); + if add.ids.is_empty() && link.remove.is_empty() { + continue; + } + events.push(tj_core::modules::link_event( + &link.task_id, + &add.ids, + &link.remove, + )); + } + + Ok(events) + })?; + append_events(&events)?; + + Ok(Json(ModuleLinkResult { + linked: events.len(), + warnings, + })) + }) + .await + }) + .await + } + + #[tool( + name = "module_backfill_candidates", + description = "Tasks that belong to no module, newest first: id, title, status, goal, outcome, files, and the modules the journal suggests for each. Sort them, confirm with the user, then module_link. `total_unlinked` counts them all; page with `limit`." + )] + async fn module_backfill_candidates( + &self, + Parameters(p): Parameters, + ) -> Result, McpError> { + traced_tool("module_backfill_candidates", async move { + run_blocking(move || { + if !has_journal()? { + return Ok(Json(BackfillResult { + total_unlinked: 0, + candidates: Vec::new(), + })); + } + + let limit = p.limit.unwrap_or(20).min(100); + let (total_unlinked, candidates) = with_state(|conn, project_hash| { + tj_core::modules::backfill_candidates(conn, project_hash, limit) + })?; + + Ok(Json(BackfillResult { + total_unlinked, + candidates, + })) + }) + .await + }) + .await + } + #[tool( name = "memory_note", description = "Remember a durable user preference or standing fact across ALL projects and sessions — how the user wants to be worked with (\"respond in Russian, terse\"), or a stable team/project rule. Injected into every future session's context. Use it when you learn something about the user or their workflow that should outlive this task. De-duplicated." @@ -1200,8 +1659,13 @@ mod tests { let create = TaskCreateResult { task_id: "tj-x".into(), title: "t".into(), + ..Default::default() }; - assert!(!keys_of(&serde_json::to_value(&create).unwrap()).contains(&"stub".to_string())); + // Empty chronicle fields stay off the wire: the reply keeps its old shape. + assert_eq!( + keys_of(&serde_json::to_value(&create).unwrap()), + vec!["task_id".to_string(), "title".to_string()] + ); let event = EventAddResult { event_id: "e".into(), @@ -1215,8 +1679,13 @@ mod tests { closed: true, note: None, completeness_gaps: Vec::new(), + module_reminder: None, + warnings: Vec::new(), }; - assert!(!keys_of(&serde_json::to_value(&close).unwrap()).contains(&"stub".to_string())); + assert_eq!( + keys_of(&serde_json::to_value(&close).unwrap()), + vec!["closed".to_string(), "task_id".to_string()] + ); } #[test] @@ -1369,6 +1838,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1415,6 +1885,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1459,6 +1930,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1472,6 +1944,7 @@ mod tests { goal: None, parent: Some(parent.clone()), session_id: None, + modules: None, })) .await .unwrap() @@ -1503,6 +1976,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1517,6 +1991,7 @@ mod tests { goal: None, parent: Some(parent.clone()), session_id: None, + modules: None, })) .await .unwrap(); @@ -1528,6 +2003,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap() @@ -1546,6 +2022,7 @@ mod tests { goal: Some("g".into()), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1584,6 +2061,7 @@ mod tests { goal: Some("ship it".into()), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1598,6 +2076,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap() @@ -1625,6 +2104,7 @@ mod tests { goal: Some("ship it".into()), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1671,6 +2151,7 @@ mod tests { goal: Some(format!("goal of {title}")), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1691,6 +2172,7 @@ mod tests { status: status.map(Into::into), project: project.map(Into::into), event_type: event_type.map(Into::into), + module: None, })) .await .map(|j| j.0) @@ -1717,6 +2199,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap(); @@ -1767,6 +2250,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap(); @@ -1857,6 +2341,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap(); @@ -1887,6 +2372,7 @@ mod tests { outcome: Some(outcome.into()), outcome_tag: Some("done".into()), session_id: None, + module_notes: None, }; // Make the close append fail: the journal is read-only. @@ -1985,6 +2471,7 @@ mod tests { goal: None, parent: None, session_id: Some("s-create".into()), + modules: None, })) .await .unwrap() @@ -2017,6 +2504,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: Some("s-close".into()), + module_notes: None, })) .await; match prev { @@ -2057,6 +2545,308 @@ mod tests { assert_eq!(p.session_id, None); } + fn create_params(title: &str, modules: Option<&[&str]>) -> TaskCreateParams { + TaskCreateParams { + title: title.into(), + initial_context: None, + goal: None, + parent: None, + session_id: None, + modules: modules.map(|m| m.iter().map(|s| s.to_string()).collect()), + } + } + + fn close_params(task_id: &str, notes: Option>) -> TaskCloseParams { + TaskCloseParams { + task_id: task_id.into(), + reason: "done".into(), + outcome: Some("shipped".into()), + outcome_tag: None, + session_id: None, + module_notes: notes.map(|n| { + n.into_iter() + .map(|(module, text)| ModuleNote { + module: module.into(), + text: text.into(), + }) + .collect() + }), + } + } + + fn save_params(id: &str, name: Option<&str>, terms: &[&str]) -> ModuleSaveParams { + ModuleSaveParams { + module_id: id.into(), + name: name.map(str::to_string), + description: None, + hints: Some(tj_core::modules::Hints { + paths: vec![], + terms: terms.iter().map(|t| t.to_string()).collect(), + }), + state: None, + status: None, + merged_into: None, + } + } + + fn journal_lines() -> usize { + let (_, events_path, _) = project_paths().unwrap(); + std::fs::read_to_string(events_path) + .map(|s| s.lines().count()) + .unwrap_or(0) + } + + #[test] + fn chronicle_params_are_optional() { + let p: TaskCreateParams = + serde_json::from_value(serde_json::json!({"title": "t"})).unwrap(); + assert!(p.modules.is_none()); + let c: TaskCloseParams = + serde_json::from_value(serde_json::json!({"task_id": "tj-a", "reason": "r"})).unwrap(); + assert!(c.module_notes.is_none()); + let s: TaskSearchParams = serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(s.module.is_none()); + + let l: ModuleLinkParams = serde_json::from_value( + serde_json::json!({"links": [{"task_id": "tj-a", "add": ["stars"]}]}), + ) + .unwrap(); + assert_eq!(l.links[0].add, vec!["stars".to_string()]); + assert!(l.links[0].remove.is_empty()); + } + + #[test] + fn instructions_teach_the_chronicle() { + for needle in ["modules=", "module_page", "module_notes", "📚 Chronicle"] { + assert!(MCP_INSTRUCTIONS.contains(needle), "{needle}"); + } + } + + #[tokio::test] + async fn a_task_lives_in_its_module_from_create_to_close() { + let _env = handler_env(); + let server = TaskJournalServer; + + let saved = server + .module_save(Parameters(save_params("mcp-flow", Some("Flow"), &[]))) + .await + .unwrap() + .0; + assert!(saved.created); + + let created = server + .task_create(Parameters(create_params("Flow work", Some(&["mcp-flow"])))) + .await + .unwrap() + .0; + assert_eq!(created.modules[0].module_id, "mcp-flow"); + + let closed = server + .task_close(Parameters(close_params( + &created.task_id, + Some(vec![("mcp-flow", "Flow now retries")]), + ))) + .await + .unwrap() + .0; + assert!( + closed + .module_reminder + .as_deref() + .unwrap_or("") + .contains("module_save(state"), + "{:?}", + closed.module_reminder + ); + + let page = server + .module_page(Parameters(ModulePageParams { + module_id: "mcp-flow".into(), + })) + .await + .unwrap() + .0; + assert!(page.text.contains("Flow now retries"), "{}", page.text); + assert!(page.text.contains(&created.task_id), "{}", page.text); + + let hits = server + .task_search(Parameters(TaskSearchParams { + query: String::new(), + status: None, + project: None, + event_type: None, + module: Some("mcp-flow".into()), + })) + .await + .unwrap() + .0; + assert_eq!(hits.results, vec![created.task_id.clone()]); + + let map = server.module_list().await.unwrap().0; + assert!(map.modules.iter().any(|m| m.module_id == "mcp-flow")); + } + + #[tokio::test] + async fn unknown_modules_and_new_modules_without_a_name_write_nothing() { + let _env = handler_env(); + let server = TaskJournalServer; + let before = journal_lines(); + + let err = server + .task_create(Parameters(create_params("Orphan", Some(&["mcp-nope"])))) + .await + .err() + .unwrap(); + assert!(err.message.contains("module_save"), "{}", err.message); + + let err = server + .module_save(Parameters(save_params("mcp-unnamed", None, &[]))) + .await + .err() + .unwrap(); + assert!(err.message.contains("name"), "{}", err.message); + + let err = server + .module_save(Parameters(save_params("Bad Slug", Some("x"), &[]))) + .await + .err() + .unwrap(); + assert!(err.message.contains("auth-refresh"), "{}", err.message); + + assert_eq!(journal_lines(), before); + } + + #[tokio::test] + async fn a_new_task_gets_module_suggestions_and_links_later() { + let _env = handler_env(); + let server = TaskJournalServer; + server + .module_save(Parameters(save_params( + "mcp-billing", + Some("Billing"), + &["invoice ledger"], + ))) + .await + .unwrap(); + + let created = server + .task_create(Parameters(create_params( + "Fix the invoice ledger rounding", + None, + ))) + .await + .unwrap() + .0; + assert!( + created + .suggested_modules + .iter() + .any(|s| s.module_id == "mcp-billing"), + "{:?}", + created.suggested_modules + ); + assert!(created.chronicle.is_some()); + + let candidates = server + .module_backfill_candidates(Parameters(BackfillParams { limit: Some(100) })) + .await + .unwrap() + .0; + assert!(candidates + .candidates + .iter() + .any(|c| c.task_id == created.task_id)); + + let linked = server + .module_link(Parameters(ModuleLinkParams { + links: vec![ModuleLink { + task_id: created.task_id.clone(), + add: vec!["mcp-billing".into()], + remove: vec![], + }], + })) + .await + .unwrap() + .0; + assert_eq!(linked.linked, 1); + + let closed = server + .task_close(Parameters(close_params(&created.task_id, None))) + .await + .unwrap() + .0; + assert!( + closed + .module_reminder + .as_deref() + .unwrap_or("") + .contains("mcp-billing"), + "{:?}", + closed.module_reminder + ); + } + + #[tokio::test] + async fn linking_redirects_a_merged_module_and_refuses_an_unknown_task() { + let _env = handler_env(); + let server = TaskJournalServer; + server + .module_save(Parameters(save_params("mcp-new", Some("New"), &[]))) + .await + .unwrap(); + server + .module_save(Parameters(save_params("mcp-old", Some("Old"), &[]))) + .await + .unwrap(); + let mut merge = save_params("mcp-old", None, &[]); + merge.hints = None; + merge.status = Some("merged".into()); + merge.merged_into = Some("mcp-new".into()); + server.module_save(Parameters(merge)).await.unwrap(); + + let task = server + .task_create(Parameters(create_params("Old area work", None))) + .await + .unwrap() + .0 + .task_id; + let linked = server + .module_link(Parameters(ModuleLinkParams { + links: vec![ModuleLink { + task_id: task.clone(), + add: vec!["mcp-old".into()], + remove: vec![], + }], + })) + .await + .unwrap() + .0; + assert!( + linked.warnings[0].contains("merged into mcp-new"), + "{:?}", + linked.warnings + ); + + let err = server + .module_link(Parameters(ModuleLinkParams { + links: vec![ModuleLink { + task_id: "tj-missing".into(), + add: vec!["mcp-new".into()], + remove: vec![], + }], + })) + .await + .err() + .unwrap(); + assert!(err.message.contains("tj-missing"), "{}", err.message); + + let mut into_self = save_params("mcp-new", None, &[]); + into_self.hints = None; + into_self.status = Some("merged".into()); + into_self.merged_into = Some("mcp-new".into()); + assert!(server.module_save(Parameters(into_self)).await.is_err()); + } + #[tokio::test] async fn task_search_of_a_project_without_a_journal_creates_no_state_db() { let _env = handler_env(); @@ -2090,6 +2880,7 @@ mod tests { goal: Some("Wire the board".into()), parent: None, session_id: None, + modules: None, })) .await; std::env::remove_var("LOOM_TASK_ID"); From 30967760d48ebebb25f1ffffd311944c975a901d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 17:04:00 +0400 Subject: [PATCH 07/23] feat(cli): module commands, --modules, --module-note, chronicle line at session start --- crates/tj-cli/src/main.rs | 261 +++++++++++++++++++++++++++++++++++++ crates/tj-cli/tests/cli.rs | 185 ++++++++++++++++++++++++++ 2 files changed, 446 insertions(+) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 193f8bb..0c04f21 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -125,6 +125,168 @@ fn dir_writable(dir: &std::path::Path) -> bool { /// Read a project's JSONL event log. Malformed lines are skipped with a /// warning on stderr, the same policy as `rebuild_state`, so one bad line /// cannot abort a read-only command. +/// Run `f` on the current project's state, with the journal ingested first. +fn with_chronicle(f: impl FnOnce(&rusqlite::Connection, &str) -> Result) -> 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 conn = tj_core::db::open(&state_path)?; + tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; + + f(&conn, &project_hash) +} + +/// Append `events` to the current project's journal, durably. +fn append_to_journal(events: &[tj_core::event::Event]) -> Result<()> { + let project_hash = tj_core::project_hash::from_path(&std::env::current_dir()?)?; + let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); + let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + for event in events { + writer.append(event)?; + } + writer.flush_durable()?; + + Ok(()) +} + +/// The CLI writes as the user, from the command line. +fn by_user(mut event: tj_core::event::Event) -> tj_core::event::Event { + event.author = tj_core::event::Author::User; + event.source = tj_core::event::Source::Cli; + + event +} + +fn run_module(action: ModuleCmd) -> Result<()> { + match action { + ModuleCmd::List { json } => { + let (modules, gaps) = with_chronicle(|conn, hash| { + Ok(( + tj_core::modules::list(conn, hash)?, + tj_core::archive::gaps(conn, hash, None)?, + )) + })?; + if json { + let out = serde_json::json!({ "modules": modules, "gaps": gaps }); + println!("{}", serde_json::to_string(&out)?); + return Ok(()); + } + + if modules.is_empty() { + println!("(no modules yet)"); + } + for m in &modules { + let last = m.last_activity.as_deref().map(|t| t.get(..10).unwrap_or(t)); + let status = if m.status == "active" { + String::new() + } else { + format!(" [{}]", m.status) + }; + println!( + "{:<20} {}{status} — {} task(s){}{}", + m.module_id, + m.name, + m.task_count, + last.map(|d| format!(", last {d}")).unwrap_or_default(), + m.description + .as_deref() + .map(|d| format!(" — {d}")) + .unwrap_or_default(), + ); + } + if let Some(line) = tj_core::archive::headline(&gaps) { + println!("\n{line}"); + } + } + ModuleCmd::Show { module_id } => { + let page = with_chronicle(|conn, hash| tj_core::modules::page(conn, hash, &module_id))?; + println!("{page}"); + } + ModuleCmd::Save { + module_id, + name, + description, + paths, + terms, + state, + status, + merged_into, + } => { + let hints = (!paths.is_empty() || !terms.is_empty()) + .then_some(tj_core::modules::Hints { paths, terms }); + let fields = tj_core::modules::ModuleFields { + name, + description, + hints, + state, + status, + merged_into, + }; + let event = tj_core::modules::module_event(&module_id, &fields)?; + + let created = with_chronicle(|conn, hash| { + let exists = tj_core::modules::get(conn, hash, &module_id)?.is_some(); + if !exists && fields.name.is_none() { + anyhow::bail!("module {module_id} is new: pass --name too"); + } + if let Some(into) = &fields.merged_into { + if into == &module_id { + anyhow::bail!("a module cannot be merged into itself"); + } + if tj_core::modules::get(conn, hash, into)?.is_none() { + anyhow::bail!("--merged-into: module {into:?} does not exist"); + } + } + + Ok(!exists) + })?; + append_to_journal(&[by_user(event)])?; + println!( + "{} {module_id}", + if created { "created" } else { "updated" } + ); + } + ModuleCmd::Link { + task_id, + add, + remove, + } => { + let resolved = with_chronicle(|conn, hash| { + if !tj_core::db::task_exists(conn, &task_id)? { + anyhow::bail!("task not found: {task_id}"); + } + for id in &remove { + tj_core::modules::validate_id(id)?; + } + + tj_core::modules::resolve(conn, hash, &add) + })?; + for w in &resolved.warnings { + eprintln!("warning: {w}"); + } + if resolved.ids.is_empty() && remove.is_empty() { + anyhow::bail!("nothing to do: pass --add or --remove"); + } + append_to_journal(&[by_user(tj_core::modules::link_event( + &task_id, + &resolved.ids, + &remove, + ))])?; + println!("linked {task_id}"); + } + ModuleCmd::Candidates { limit } => { + let (total, candidates) = with_chronicle(|conn, hash| { + tj_core::modules::backfill_candidates(conn, hash, limit) + })?; + let out = serde_json::json!({ "total_unlinked": total, "candidates": candidates }); + println!("{}", serde_json::to_string(&out)?); + } + } + + Ok(()) +} + fn read_events_lenient( path: &std::path::Path, command: &str, @@ -825,6 +987,9 @@ enum Commands { /// Parent task id — makes this a subtask of the given id. #[arg(long)] parent: Option, + /// Modules the task belongs to, comma-separated (see `module list`). + #[arg(long, value_delimiter = ',')] + modules: Vec, }, /// List tasks for the current project. List { @@ -976,6 +1141,10 @@ enum Commands { /// primary field; the tag is for filtering / aggregation. #[arg(long)] outcome_tag: Option, + /// A line of a module's history: `module=what this task changed there`. + /// Repeat for several modules. + #[arg(long = "module-note")] + module_notes: Vec, }, /// Attach a clickable, typed link to a task (doc, deploy, dashboard, /// design, …). Renders under the pack's Artifacts as `[label](url)` so a @@ -1040,6 +1209,12 @@ enum Commands { #[arg(long = "add")] add: String, }, + /// The project chronicle: modules (parts of the system by meaning) and + /// their history. + Module { + #[command(subcommand)] + action: ModuleCmd, + }, /// Re-run artifact extraction over every event of a task and /// refresh the pack cache. Use after upgrading from v0.4.x — older /// events were ingested before the artifact column was populated, @@ -1351,6 +1526,52 @@ enum EventsCmd { }, } +#[derive(Subcommand)] +enum ModuleCmd { + /// The module map and what the chronicle is missing. + List { + #[arg(long)] + json: bool, + }, + /// A module's page: state, decisions, rejections, constraints, history. + Show { module_id: String }, + /// Create or update a module; only the fields given change. + Save { + module_id: String, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + /// A code path prefix that marks the module (repeatable). + #[arg(long = "path")] + paths: Vec, + /// A term of the module (repeatable). + #[arg(long = "term")] + terms: Vec, + /// How the module works now. + #[arg(long)] + state: Option, + /// `active`, `retired` or `merged` (with `--merged-into`). + #[arg(long)] + status: Option, + #[arg(long)] + merged_into: Option, + }, + /// Link a task to modules, or unlink it. + Link { + task_id: String, + #[arg(long = "add")] + add: Vec, + #[arg(long = "remove")] + remove: Vec, + }, + /// Tasks that belong to no module, with suggestions (JSON). + Candidates { + #[arg(long, default_value_t = 20)] + limit: usize, + }, +} + #[derive(Subcommand)] enum PendingCmd { /// List queued classifier failures. @@ -1404,6 +1625,7 @@ fn real_main() -> Result<()> { context, goal, parent, + modules, } => { let cwd = std::env::current_dir()?; let project_hash = tj_core::project_hash::from_path(&cwd)?; @@ -1445,6 +1667,14 @@ fn real_main() -> Result<()> { if let Some(g) = goal { meta["goal"] = serde_json::Value::String(g); } + if !modules.is_empty() { + let resolved = + with_chronicle(|conn, hash| tj_core::modules::resolve(conn, hash, &modules))?; + for w in &resolved.warnings { + eprintln!("warning: {w}"); + } + meta["modules"] = serde_json::json!(resolved.ids); + } event.meta = meta; let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; @@ -1827,6 +2057,7 @@ fn real_main() -> Result<()> { reason, outcome, outcome_tag, + module_notes, } => { let cwd = std::env::current_dir()?; let project_hash = tj_core::project_hash::from_path(&cwd)?; @@ -1844,6 +2075,13 @@ fn real_main() -> Result<()> { ), } } + let mut notes = Vec::new(); + for note in &module_notes { + let Some((module, text)) = note.split_once('=') else { + anyhow::bail!("--module-note expects module=text, got {note:?}"); + }; + notes.push((module.trim().to_string(), text.trim().to_string())); + } // Catch up the index then assert the task is real before we // append a close event for an id that never existed. @@ -1855,6 +2093,15 @@ fn real_main() -> Result<()> { anyhow::bail!("task not found: {task_id}"); } let open_kids = tj_core::db::count_open_children(&conn, &task_id)?; + // A note to an unknown module fails the close before it is written. + for (module, _) in notes.iter_mut() { + let resolved = + tj_core::modules::resolve(&conn, &project_hash, std::slice::from_ref(module))?; + for w in &resolved.warnings { + eprintln!("warning: {w}"); + } + *module = resolved.ids[0].clone(); + } drop(conn); let mut event = tj_core::event::Event::new( @@ -1877,6 +2124,9 @@ fn real_main() -> Result<()> { if let Some(t) = outcome_tag { meta.insert("outcome_tag".into(), serde_json::Value::String(t)); } + if !notes.is_empty() { + meta.insert("module_notes".into(), tj_core::modules::notes_meta(¬es)); + } // Layer-2 close harvest: stamp deterministic git/gh refs (commit, // branch, PR) so the closed pack reads as a clickable ledger of // what shipped. Best-effort; structured artifacts are merged in @@ -2056,6 +2306,7 @@ fn real_main() -> Result<()> { tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; println!("ok"); } + Commands::Module { action } => run_module(action)?, Commands::External { task_id, add } => { let cwd = std::env::current_dir()?; let project_hash = tj_core::project_hash::from_path(&cwd)?; @@ -2721,6 +2972,16 @@ runs in the background and won't block you; it only fills gaps and never closes )); } } + // The chronicle's most important gap, for every client that + // reads this hook (Claude Code with or without the mod, Codex). + // Advice only: it never fails the session start. + if let Some(line) = tj_core::archive::gaps(&conn, &project_hash, None) + .ok() + .and_then(|gaps| tj_core::archive::headline(&gaps)) + { + bundle.push_str(&line); + 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 8d1da06..e7ce5e7 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -8551,3 +8551,188 @@ fn module_lines_stay_out_of_task_views_but_not_json_export() { .success() .stdout(contains("mod:stars")); } + +/// A `task-journal` command run in its own data home and project dir. +fn chronicle_cli<'a>( + xdg: &'a assert_fs::TempDir, + proj: &'a assert_fs::TempDir, +) -> impl Fn() -> assert_cmd::Command + 'a { + move || { + let mut c = Command::cargo_bin("task-journal").unwrap(); + c.env("XDG_DATA_HOME", xdg.path()).current_dir(proj.path()); + c + } +} + +fn stdout_of(mut cmd: assert_cmd::Command) -> String { + String::from_utf8(cmd.assert().success().get_output().stdout.clone()) + .unwrap() + .trim() + .to_string() +} + +#[test] +fn module_lifecycle_through_the_cli() { + let (xdg, proj) = ( + assert_fs::TempDir::new().unwrap(), + assert_fs::TempDir::new().unwrap(), + ); + let tj = chronicle_cli(&xdg, &proj); + + tj().args([ + "module", + "save", + "stars", + "--name", + "Stars", + "--path", + "src/stars/", + "--term", + "star feed", + ]) + .assert() + .success(); + let mut create = tj(); + create.args(["create", "Rank stars", "--modules", "stars"]); + let id = stdout_of(create); + tj().args([ + "close", + &id, + "--outcome", + "Ranked", + "--module-note", + "stars=Ranking by Wilson score", + ]) + .assert() + .success(); + + tj().args(["module", "show", "stars"]) + .assert() + .success() + .stdout( + contains("# Stars (stars)") + .and(contains("Ranking by Wilson score")) + .and(contains(id.as_str())), + ); + tj().args(["pack", &id]) + .assert() + .success() + .stdout(contains("**Modules**: Stars (stars)")); + tj().args(["module", "list", "--json"]) + .assert() + .success() + .stdout(contains(r#""module_id":"stars""#)); + tj().args(["module", "list"]) + .assert() + .success() + .stdout(contains("stars").and(contains("Stars"))); +} + +#[test] +fn module_link_and_candidates_through_the_cli() { + let (xdg, proj) = ( + assert_fs::TempDir::new().unwrap(), + assert_fs::TempDir::new().unwrap(), + ); + let tj = chronicle_cli(&xdg, &proj); + tj().args([ + "module", + "save", + "auth", + "--name", + "Auth", + "--term", + "token refresh", + ]) + .assert() + .success(); + let mut create = tj(); + create.args(["create", "Fix token refresh race"]); + let id = stdout_of(create); + + let mut candidates = tj(); + candidates.args(["module", "candidates"]); + let json: serde_json::Value = serde_json::from_str(&stdout_of(candidates)).unwrap(); + assert_eq!(json["total_unlinked"], 1); + assert_eq!(json["candidates"][0]["suggestions"][0]["module_id"], "auth"); + + tj().args(["module", "link", &id, "--add", "auth"]) + .assert() + .success(); + tj().args(["pack", &id]) + .assert() + .success() + .stdout(contains("**Modules**: Auth (auth)")); + tj().args(["module", "link", "tj-missing", "--add", "auth"]) + .assert() + .failure() + .stderr(contains("tj-missing")); +} + +#[test] +fn unknown_module_and_bad_slug_fail_before_writing() { + let (xdg, proj) = ( + assert_fs::TempDir::new().unwrap(), + assert_fs::TempDir::new().unwrap(), + ); + let tj = chronicle_cli(&xdg, &proj); + + tj().args(["create", "Orphan", "--modules", "nope"]) + .assert() + .failure() + .stderr(contains("module_save")); + tj().args(["module", "save", "Bad Slug", "--name", "x"]) + .assert() + .failure() + .stderr(contains("auth-refresh")); + tj().args(["module", "save", "unnamed"]) + .assert() + .failure() + .stderr(contains("--name")); + tj().args(["close", "tj-x", "--module-note", "no-equals-sign"]) + .assert() + .failure() + .stderr(contains("module=text")); + + assert!( + !xdg.path().join("task-journal/events").exists() + || std::fs::read_dir(xdg.path().join("task-journal/events")) + .unwrap() + .all(|e| std::fs::metadata(e.unwrap().path()).unwrap().len() == 0), + "a failed command wrote to the journal" + ); +} + +#[test] +fn session_start_names_the_top_chronicle_gap() { + let (xdg, proj) = ( + assert_fs::TempDir::new().unwrap(), + assert_fs::TempDir::new().unwrap(), + ); + let tj = chronicle_cli(&xdg, &proj); + tj().args(["create", "Some work"]).assert().success(); + + let mut hook = tj(); + hook.args(["ingest-hook", "--kind", "SessionStart", "--text", ""]); + let out: serde_json::Value = serde_json::from_str(&stdout_of(hook)).unwrap(); + let ctx = out["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap(); + + assert!(ctx.contains("📚 Chronicle: no module map yet"), "{ctx}"); +} + +#[test] +fn state_carries_archive_gaps() { + let (xdg, proj) = ( + assert_fs::TempDir::new().unwrap(), + assert_fs::TempDir::new().unwrap(), + ); + let tj = chronicle_cli(&xdg, &proj); + tj().args(["create", "Some work"]).assert().success(); + + tj().args(["state"]) + .assert() + .success() + .stdout(contains(r#""kind":"no_map""#)); +} From aaee3712c676dc7e177b3db5eff68c5879657112 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 17:05:52 +0400 Subject: [PATCH 08/23] feat(mod): module in the status line, chronicle gaps that open mid-session --- plugin/hooks/journal.test.ts | 57 ++++++++++++++++++++++++++++++++++-- plugin/hooks/journal.ts | 50 +++++++++++++++++++++++++++++-- plugin/hooks/register.ts | 31 ++++++++++++++++---- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/plugin/hooks/journal.test.ts b/plugin/hooks/journal.test.ts index 1c269b6..f85f215 100644 --- a/plugin/hooks/journal.test.ts +++ b/plugin/hooks/journal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'claude-code/testing' import { + chronicleNudge, journalTool, nudgeText, parseDistill, @@ -33,7 +34,7 @@ describe('parseState', () => { test('no active task is a state, not a failure', () => { const s = parseState(JSON.stringify({ schema: 'tj-state/1', open_tasks: 0, active: null })) - expect(s).toEqual({ active: null, open_tasks: 0 }) + expect(s).toEqual({ active: null, open_tasks: 0, archive: [] }) }) test('rejects garbage and other schemas', () => { @@ -50,12 +51,12 @@ describe('parseState', () => { describe('texts', () => { const s = parseState(ACTIVE) as JournalState - const none: JournalState = { active: null, open_tasks: 3 } + const none: JournalState = { active: null, open_tasks: 3, archive: [] } test('status line names the task and its counts', () => { expect(statusText(s)).toBe('📓 tj-abc · 3 decisions · 1 rejected · 0 evidence') expect(statusText(none)).toBe('📓 no task in this session · 3 open') - expect(statusText({ active: null, open_tasks: 0 })).toBe('📓 no task') + expect(statusText({ active: null, open_tasks: 0, archive: [] })).toBe('📓 no task') }) test('the prompt section holds nothing that changes per entry', () => { @@ -72,6 +73,56 @@ describe('texts', () => { }) }) +describe('chronicle', () => { + const withGaps = (archive: unknown[], modules: string[] = []) => + parseState( + JSON.stringify({ + schema: 'tj-state/1', + open_tasks: 1, + archive, + active: { task_id: 'tj-a', title: 'T', goal: null, counts: {}, recent: [], modules }, + }), + ) as JournalState + + test('status line names the task modules', () => { + expect(statusText(withGaps([], ['Stars', 'Auth']))).toBe( + '📓 tj-a [Stars, Auth] · 0 decisions · 0 rejected · 0 evidence', + ) + }) + + test('an older CLI without archive or modules parses as none', () => { + const s = parseState(ACTIVE) as JournalState + expect(s.archive).toEqual([]) + expect(s.active?.modules).toEqual([]) + }) + + test('the active task without a module comes first', () => { + const s = withGaps([ + { kind: 'unlinked_tasks', count: 4 }, + { kind: 'task_without_module', task_id: 'tj-a' }, + ]) + const n = chronicleNudge(s) + expect(n?.key).toBe('task_without_module:tj-a') + expect(n?.text).toContain('📚 Chronicle: the active task tj-a belongs to no module') + }) + + test('otherwise the first gap, keyed so a changed count is not new', () => { + const a = chronicleNudge(withGaps([{ kind: 'unlinked_tasks', count: 4 }])) + const b = chronicleNudge(withGaps([{ kind: 'unlinked_tasks', count: 5 }])) + expect(a?.key).toBe(b?.key) + expect(a?.text).toContain('module_backfill_candidates') + expect(chronicleNudge(withGaps([{ kind: 'no_map', tasks: 3 }]))?.text).toContain('/task-journal:map') + expect(chronicleNudge(withGaps([{ kind: 'stale_module', module_id: 'stars', closed_since: 2 }]))?.key).toBe( + 'stale_module:stars', + ) + }) + + test('no gaps, or only unknown ones, is no nudge', () => { + expect(chronicleNudge(withGaps([]))).toBe(null) + expect(chronicleNudge(withGaps([{ kind: 'from_the_future' }]))).toBe(null) + }) +}) + describe('journalTool', () => { test('matches the plugin server and a hand-wired one', () => { expect(journalTool('mcp__plugin_task-journal_task-journal__event_add')).toBe('event_add') diff --git a/plugin/hooks/journal.ts b/plugin/hooks/journal.ts index 64f7b9f..a464796 100644 --- a/plugin/hooks/journal.ts +++ b/plugin/hooks/journal.ts @@ -9,11 +9,18 @@ export type ActiveTask = { goal: string | null counts: Record recent: JournalEvent[] + /** Names of the modules the task belongs to. */ + modules: string[] } +/** What the project chronicle is missing: `kind` plus its own fields. */ +export type ArchiveGap = { kind: string; [field: string]: unknown } + export type JournalState = { active: ActiveTask | null open_tasks: number + /** Most important first. Empty from a CLI older than 0.31. */ + archive: ArchiveGap[] } export const STATE_SCHEMA = 'tj-state/1' @@ -48,9 +55,14 @@ export function parseState(stdout: string): JournalState | null { if (v.schema !== STATE_SCHEMA) return null const open = typeof v.open_tasks === 'number' ? v.open_tasks : 0 + const archive = Array.isArray(v.archive) + ? v.archive.filter( + (g): g is ArchiveGap => typeof g === 'object' && g !== null && typeof (g as ArchiveGap).kind === 'string', + ) + : [] const a = v.active as Record | null | undefined if (a === null || a === undefined || typeof a.task_id !== 'string') { - return { active: null, open_tasks: open } + return { active: null, open_tasks: open, archive } } const recent = Array.isArray(a.recent) @@ -67,8 +79,10 @@ export function parseState(stdout: string): JournalState | null { goal: typeof a.goal === 'string' && a.goal.trim() !== '' ? a.goal : null, counts: (a.counts as Record) ?? {}, recent, + modules: Array.isArray(a.modules) ? a.modules.filter((m): m is string => typeof m === 'string') : [], }, open_tasks: open, + archive, } } @@ -79,8 +93,40 @@ export function statusText(state: JournalState): string { } const n = (k: string) => a.counts[k] ?? 0 + const where = a.modules.length > 0 ? ` [${a.modules.join(', ')}]` : '' + + return `📓 ${a.task_id}${where} · ${n('decision')} decisions · ${n('rejection')} rejected · ${n('evidence')} evidence` +} + +const GAP_TEXT: Record string> = { + no_map: g => + `no module map yet (${g.tasks} tasks) — map the project with /task-journal:map and confirm the modules with the user`, + unlinked_tasks: g => + `${g.count} task(s) belong to no module — sort them with module_backfill_candidates, confirm with the user, then module_link`, + stale_module: g => + `module ${g.module_id}: ${g.closed_since} closed task(s) not reflected in its state — read module_page and rewrite it with module_save(state=...)`, + task_without_module: g => `the active task ${g.task_id} belongs to no module — link it with module_link (module_list shows the map)`, +} + +// The field that tells one gap of a kind from another; counts are left out, +// so a gap whose count moved is not new. +const GAP_SUBJECT: Record = { stale_module: 'module_id', task_without_module: 'task_id' } + +/** + * The chronicle gap to bring up, keyed so the same gap is not brought up + * twice. The session's own task without a module comes first: it is the + * one gap the agent can close right now. + */ +export function chronicleNudge(state: JournalState): { key: string; text: string } | null { + const own = state.archive.find(g => g.kind === 'task_without_module') + const gap = own ?? state.archive.find(g => GAP_TEXT[g.kind] !== undefined) + const describe = gap === undefined ? undefined : GAP_TEXT[gap.kind] + if (gap === undefined || describe === undefined) return null + + const subject = GAP_SUBJECT[gap.kind] + const key = subject === undefined ? gap.kind : `${gap.kind}:${String(gap[subject])}` - return `📓 ${a.task_id} · ${n('decision')} decisions · ${n('rejection')} rejected · ${n('evidence')} evidence` + return { key, text: `📚 Chronicle: ${describe(gap)}. Close this gap once the current work is done.` } } /** diff --git a/plugin/hooks/register.ts b/plugin/hooks/register.ts index 3d8e700..d9c41e0 100644 --- a/plugin/hooks/register.ts +++ b/plugin/hooks/register.ts @@ -7,6 +7,8 @@ // its own active task; // - shows the task in the status line and a toast on every entry; // - reminds the agent to log only after N turns without an entry; +// - brings up a project-chronicle gap that opened during the session (the +// session start already showed the one it began with); // - before a compaction, asks the model (from its own prompt cache) what // was not logged and records it as suggested events. // @@ -17,6 +19,7 @@ import type { EngineInterface, Register } from 'claude-code' import { + chronicleNudge, compactInstruction, distillPrompt, journalTool, @@ -62,6 +65,9 @@ const mod = { pinned: null as string | null, turnsSinceEntry: 0, workSinceEntry: 0, + // The chronicle gap last brought up. Seeded at the session's start with + // the gap the SessionStart hook shows, so it is not repeated. + chronicleKey: null as string | null, } type CliResult = @@ -122,7 +128,10 @@ async function refresh($: EngineInterface): Promise { return } - if (mod.session !== session) mod.pinned = null + if (mod.session !== session) { + mod.pinned = null + mod.chronicleKey = chronicleNudge(state)?.key ?? null + } mod.session = session mod.state = state mod.pinned = state.active?.task_id ?? null @@ -219,6 +228,7 @@ export const register: Register = (on, options) => { mod.retryAt = 0 mod.turnsSinceEntry = 0 mod.workSinceEntry = 0 + mod.chronicleKey = null return next(e) }) @@ -244,14 +254,25 @@ export const register: Register = (on, options) => { const state = await current($) if (state === null) return next(e) + // Both reminders ride in the prompt's context, never in the system + // prompt: a change there would re-send the conversation uncached. + const added: string[] = [] + const chronicle = chronicleNudge(state) + if (chronicle !== null && chronicle.key !== mod.chronicleKey) { + mod.chronicleKey = chronicle.key + added.push(chronicle.text) + } + mod.turnsSinceEntry += 1 const isDue = nudgeAfter > 0 && mod.turnsSinceEntry > nudgeAfter && (state.active !== null || mod.workSinceEntry > 0) - if (!isDue) return next(e) - - mod.turnsSinceEntry = 0 + if (isDue) { + mod.turnsSinceEntry = 0 + added.push(nudgeText(state, nudgeAfter)) + } + if (added.length === 0) return next(e) - return next({ ...e, context: [...(e.context ?? []), nudgeText(state, nudgeAfter)] }) + return next({ ...e, context: [...(e.context ?? []), ...added] }) }) on('tool.call', async ($, e, next) => { From 02f3b5cf99e0ba9df4e5f343633d8cda278819c6 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 17:06:40 +0400 Subject: [PATCH 09/23] docs(plugin): chronicle ritual in the skill and /task-journal:map --- crates/tj-mcp/tests/skill_doc.rs | 34 ++++++++++++++++++ plugin/commands/map.md | 26 ++++++++++++++ plugin/skills/task-journal/SKILL.md | 55 +++++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 plugin/commands/map.md diff --git a/crates/tj-mcp/tests/skill_doc.rs b/crates/tj-mcp/tests/skill_doc.rs index f45c5fd..d18bbfc 100644 --- a/crates/tj-mcp/tests/skill_doc.rs +++ b/crates/tj-mcp/tests/skill_doc.rs @@ -65,3 +65,37 @@ fn skill_frames_self_tagging_as_primary() { "skill must frame self-tagging as the primary capture path" ); } + +#[test] +fn skill_teaches_the_chronicle() { + let s = skill_md(); + for needle in [ + "## Chronicle", + "module_list", + "module_page", + "module_save", + "module_link", + "module_backfill_candidates", + "modules=", + "module_notes", + "📚 Chronicle", + "confirm", + ] { + assert!(s.contains(needle), "skill must mention {needle}"); + } +} + +#[test] +fn map_command_exists_and_asks_the_user_first() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../plugin/commands/map.md"); + let s = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("map command missing at {}: {e}", path.display())) + .replace("\r\n", "\n"); + + assert!(s.starts_with("---\n") && s.contains("\ndescription:"), "frontmatter"); + for needle in ["module_list", "module_save", "module_backfill_candidates", "module_link", "confirm"] { + assert!(s.contains(needle), "map command must mention {needle}"); + } + // Saving comes only after the user's answer. + assert!(s.find("confirm").unwrap() < s.find("module_save").unwrap()); +} diff --git a/plugin/commands/map.md b/plugin/commands/map.md new file mode 100644 index 0000000..03443a4 --- /dev/null +++ b/plugin/commands/map.md @@ -0,0 +1,26 @@ +--- +description: Map the project into modules and sort past tasks into them (Task Journal chronicle) +argument-hint: "[area to focus on]" +--- + +Build or extend this project's module map in Task Journal: the parts of the system by +meaning, each with its history of tasks. + +Focus (optional): $ARGUMENTS + +1. Call `module_list`. If modules exist, extend the map rather than starting over. +2. Study the project: layout, README and docs, the key code paths. +3. Propose modules as a short list — for each: id (lowercase slug like `stars`, + `auth-refresh`), name, one to three sentences on what it is, `hints.paths` (code path + prefixes) and `hints.terms` (words people use for it). Ask the user to confirm or + correct the list, and wait for the answer. +4. After the user's yes, `module_save` each confirmed module. +5. Call `module_backfill_candidates(limit=50)`, page by page. Assign each task to modules + using its title, goal, outcome, files and the suggestions; list the doubtful ones + separately. +6. Show the assignment to the user and ask them to confirm it. After a yes, make one + `module_link` call per page. +7. For every module with tasks, read `module_page` and write its first `state` with + `module_save(module_id, state=...)`: a few sentences on how it works now. + +Finish with `module_list` and tell the user what the map holds and what is left. diff --git a/plugin/skills/task-journal/SKILL.md b/plugin/skills/task-journal/SKILL.md index 9087e9a..d20a85c 100644 --- a/plugin/skills/task-journal/SKILL.md +++ b/plugin/skills/task-journal/SKILL.md @@ -7,11 +7,12 @@ description: | tools yourself at the decisive moments below. Open a task with an explicit goal at the start, append a typed event the moment you decide / reject / discover / prove something, and close with a written outcome so the - resume pack comes out clean: Goal -> Decisions -> Outcome. + resume pack comes out clean: Goal -> Decisions -> Outcome. Keep the project + chronicle too: every task belongs to modules (parts of the system), each with a history. Triggers: start of any task/bug/investigation; a choice is committed; an approach is ruled out; a fact is verified from code or logs; a test/benchmark proves something; an earlier belief turns out wrong; task finished; "what was I working on?", "remind me - about X", "почему мы сделали так?". + about X", "почему мы сделали так?"; "map the project", "what do we know about ?". --- # Task Journal — Reasoning Chain Memory (self-tagging first) @@ -27,7 +28,8 @@ treat it as if it does nothing and record explicitly. there an open task for this? Each entry in `tasks` shows its title and goal. If yes, `task_pack(task_id)` and continue it. **Do not** open a duplicate. 2. If nothing fits, `task_create(title=, goal=)`. **Always pass `goal`** — it is the first line of every pack and - the anchor for "why was this done?". + the anchor for "why was this done?". Pass `modules=[...]` when you know the part of the + system it touches (see Chronicle below). 3. Hold the returned `task_id` for the whole task. One task = one logical objective. Events accumulate under it; do not spawn a new task per turn. @@ -107,15 +109,56 @@ An empty or absent `query` lists the tasks, newest activity first. The result ha `results` (task ids) and `tasks` — the same ids in the same order, each with `title`, `status`, `last_event_at` and `goal`. +## Chronicle — modules and their history + +The project is a map of **modules**: parts of the system by meaning — not folders, not +tickets. Every task belongs to one or more modules, and each module keeps a page: how it +works now (`state`), what is decided, rejected and constrained there across all its tasks, +and every task in order. Six months later, `module_page` is how you learn a part of the +system before touching it. + +- **Start:** `task_create(..., modules=["stars"])`. Not sure which? The reply carries + `suggested_modules` — link with `module_link`, or create a module with `module_save`. + Read `module_page(module_id)` before you change a module. +- **Done:** `task_close(..., module_notes=[{"module": "stars", "text": "ranking by Wilson score"}])` + — one line per module on what changed. If the module works differently now, rewrite its + `state` with `module_save(module_id, state=...)`. +- **Gaps:** a line starting with `📚 Chronicle:` (session start, `task_create`, `module_list`) + names what the chronicle is missing — no map, tasks without a module, a module whose + state lags behind its tasks. Close it once the current work is done. + +### Mapping a project (no map yet, or `/task-journal:map`) + +1. `module_list` — extend an existing map rather than starting over. +2. Study the project: layout, README and docs, the key code paths. +3. Propose 5–15 modules: id (`stars`, `auth-refresh`), name, one to three sentences, + `hints.paths` (code path prefixes) and `hints.terms` (the words people use for it). +4. Show the list to the user and **confirm** it; adjust from the answer. +5. `module_save` each confirmed module. +6. `module_backfill_candidates(limit=50)`, page by page: assign each old task to modules by + its title, goal, outcome, files and the suggestions; list the doubtful ones separately. +7. Show the assignment to the user, **confirm**, then one `module_link` call per page. +8. For each module with tasks, read its `module_page` and write its first `state`. + +Chronicle tools: + +- `module_list()` → `modules` (id, name, description, task_count, last_activity) + `gaps`. +- `module_page(module_id)` → `text`: the module's page in Markdown. +- `module_save(module_id, name?, description?, hints?{paths, terms}, state?, status?, merged_into?)` + — partial update; a new module needs `name`. `status`: active | retired | merged. +- `module_link(links=[{task_id, add?: [...], remove?: [...]}])` — many tasks at once. +- `module_backfill_candidates(limit?)` → `total_unlinked`, `candidates` (id, title, goal, + outcome, files, suggestions). + ## The 5 MCP tools (exact params) -- `task_create(title, goal?, initial_context?, parent?)` → `task_id` like `tj-x9rz1f`. **Pass `goal`.** +- `task_create(title, goal?, initial_context?, parent?, modules?)` → `task_id` like `tj-x9rz1f`. **Pass `goal`.** - `event_add(task_id, event_type, text, corrects?, supersedes?, alternatives?)` → `event_id`. `event_type` ∈ hypothesis | finding | evidence | decision | rejection | constraint | correction | reopen | supersede | redirect. `alternatives` is decision-only. -- `task_close(task_id, reason, outcome?, outcome_tag?)` — **always pass `outcome` + `outcome_tag`.** +- `task_close(task_id, reason, outcome?, outcome_tag?, module_notes?)` — **always pass `outcome` + `outcome_tag`.** - `task_pack(task_id, mode?)` — `compact` | `full`. -- `task_search(query?, status?, project?, event_type?)` — full-text search over the project's +- `task_search(query?, status?, project?, event_type?, module?)` — full-text search over the project's events; no `query` lists its tasks. Returns `results` (ids) and `tasks` (id, title, status, goal). ## Invariants From 0f0a1f07a95aa3642bbc36d2b6e752994b499f8f Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Sun, 4 Oct 2026 17:08:03 +0400 Subject: [PATCH 10/23] =?UTF-8?q?chore:=200.31.0=20=E2=80=94=20project=20c?= =?UTF-8?q?hronicle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 4 +-- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++ Cargo.lock | 6 ++-- Cargo.toml | 4 +-- INSTALL.md | 4 +++ README.md | 16 +++++++++-- crates/tj-mcp/tests/skill_doc.rs | 13 +++++++-- plugin/.claude-plugin/plugin.json | 2 +- plugin/package.json | 2 +- 9 files changed, 85 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index da354d1..0cf7f69 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Task Journal — append-only reasoning chain memory for AI-coding tasks", - "version": "0.30.0" + "version": "0.31.0" }, "plugins": [ { "name": "task-journal", "source": "./plugin", "description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.", - "version": "0.30.0", + "version": "0.31.0", "author": { "name": "Digital-Threads" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 735b8f1..0190ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.31.0] - 2026-10-04 + +The journal becomes a map of the system. A project splits into modules — parts +of the system by meaning, not folders or tickets — every task belongs to one or +more, and each module keeps its history. The agent keeps this chronicle up by +itself: the journal says what is missing and what to do about it. + +### Added +- **Modules.** `module_save` creates or updates a module: name, description, + `hints` (code path prefixes and terms), `state` — a short living text of how + it works now — and status (`active`, `retired`, `merged`). Modules live in + the journal as a new `module` event, so a rebuild restores them. +- **Links.** `task_create(modules=[...])`, `module_link` for many tasks at once, + and `task_close(module_notes=[...])`: one line per module on what the task + changed there. Links ride in the `open` / `amend` / `close` events 0.30 + already reads. An unknown module fails the call before anything is written; + a merged module links to the one that took it over. +- **Module page.** `module_page` (CLI `module show`): the module's state, the + active decisions, rejections and constraints of all its tasks, open tasks, + and history. A task's pack names its modules. +- **Suggestions without a model.** A new task without modules gets + `suggested_modules` from its words (English and Russian forms) and, for old + tasks, its files. +- **The chronicle keeps itself up.** The journal names what is missing — no + module map, tasks without a module, a module whose state lags behind its + tasks, an active task without a module — at session start (every client, + Codex included), in `task_create` / `task_close` / `module_list` replies, in + `task-journal state` (`archive`), and in the Claude Code mod when a gap opens + mid-session. The mod's status line shows the task's modules. +- **Mapping and sorting old work.** `/task-journal:map` and the skill's steps: + propose modules, confirm with the user, save them, sort past tasks with + `module_backfill_candidates`, confirm, link, write each module's first state. +- CLI: `module list|show|save|link|candidates`, `create --modules`, + `close --module-note module=text`; `task_search` / MCP gain a `module` filter. + +### Changed +- `migrate-project` re-keys the module tables too. +- Task views (`events list`, `export --format md|html|sqlite`, backfill) skip + `module` lines; `export --format json` keeps the full journal. + +### Upgrading +- Restart open Claude Code and Codex sessions after `cargo install --force`. + The first command re-indexes the project once. +- A 0.30 binary next to 0.31 skips `module` lines with a warning and keeps all + tasks; 0.31 re-indexes once afterwards, so no module is lost. +- Nothing changes until you create a module: existing tools, packs and replies + look as before. + ## [0.30.0] - 2026-10-04 Claude Code 2.1.287 added mods: plugins that run inside Claude Code and can diff --git a/Cargo.lock b/Cargo.lock index 321513b..376d6cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "assert_cmd", @@ -2540,7 +2540,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "chrono", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.30.0" +version = "0.31.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 42b3c2a..fc93940 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.30.0" +version = "0.31.0" edition = "2021" rust-version = "1.88" license = "MIT" @@ -20,7 +20,7 @@ categories = ["command-line-utilities", "development-tools"] [workspace.dependencies] # `version` must equal workspace.package.version: `cargo publish` uploads it as # the requirement on core. Cargo can't inherit it; plugin_metadata.rs checks it. -tj-core = { package = "task-journal-core", version = "0.30.0", path = "crates/tj-core", default-features = false } +tj-core = { package = "task-journal-core", version = "0.31.0", path = "crates/tj-core", default-features = false } anyhow = "1" thiserror = "1" serde = { version = "1", features = ["derive"] } diff --git a/INSTALL.md b/INSTALL.md index cd63c58..9dabf7a 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -157,6 +157,10 @@ keeps writing next to the new one. The first command after the upgrade re-indexes the project once (a few seconds for a large journal); later calls are incremental. +0.31 adds `module` lines to the journal (the project chronicle). A 0.30 binary +still running next to it skips those lines with a warning and keeps every task; +the next 0.31 command notices and re-indexes once, so no module is lost. + ## Verify ```bash diff --git a/README.md b/README.md index f6db340..6f94f52 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ that tie it back to the code. - **The mod (Claude Code 2.1.287+).** A plugin of function hooks that runs inside Claude Code and calls the `task-journal` CLI. It keeps the session's active task in the system prompt (a compaction can't drop it), gives each session its own active task, shows the task in the status line, reminds the agent to log only after several prompts without an entry, and — right before a compaction — asks the model what was never logged and records it as `suggested` events. It needs no setup. When it runs, the classic hooks skip what it replaces (the reminder, per-message classification and the transcript catch-ups) and keep the rest (resume packs, push-recall, `/rewind`). Codex and Claude Code older than 2.1.287 keep using the hooks below; there the mod simply doesn't load. - **Self-tagging is the primary path (recommended).** You — the agent in the live session — record reasoning directly via the five MCP tools: open a task with a `goal`, append a typed `decision` / `finding` / `rejection` / `evidence` event at the moment of commitment, and `task_close` with a written `outcome`. This is free (it rides the interactive session), language-agnostic, and higher-fidelity than any after-the-fact classifier. The bundled `task-journal` skill drives this automatically. See [MCP tools](#mcp-tools). - **Auto-capture is an opt-in backstop — OFF by default (v0.14.0).** A fresh `install-hooks` wires only a cheap, read-only SessionStart resume hook: no per-message classifier runs, no `claude -p` is ever spawned, nothing is charged. Self-tagging is the capture mechanism. Opt in with `install-hooks --auto-capture` and Claude Code hooks also run every prompt, tool call, and reply through a two-stage classifier that lands typed events on its own: Stage 1 is a fast in-process heuristic (obvious EN+RU phrasing, zero cost); Stage 2 falls back to an LLM only when the heuristic is uncertain (and only if you pick `--backend agent-sdk` / `api`). Even opted in it is a safety net under your explicit self-tagging, not the main mechanism, and it misses real reasoning — especially non-English prose. +- **Project chronicle (0.31).** The journal is also a map of the system: the project splits into **modules** — parts of the system by meaning, not folders or tickets — and every task belongs to one or more. Each module has a page: how it works now, the decisions, rejections and constraints of all its tasks, and its history, one line per task. A new task can start from that page; closing it adds its line. The journal suggests a module for a new task (from its words and files, no model call) and names what the chronicle is missing — no map yet, tasks without a module, a module whose state lags behind its tasks — at session start and in tool replies, so the agent keeps it up by itself. `/task-journal:map` (or the skill's steps in any client) maps a project and sorts its past tasks, with the user confirming both. - **Artifact extraction.** Each event scans its text for commit hashes, PR URLs, file paths, issue IDs, and branch names. Aggregated artifacts are how Task Journal links related tasks: when you start a new task touching the same issue or file, the prior task is surfaced automatically. - **Resume packs.** `task_pack` (MCP tool or CLI) renders a task into a compact Markdown briefing — Goal, Outcome, decisions, rejections, evidence, artifacts — that fits in a fresh agent's context window without dumping the raw event log. - **Auto-capture boundaries.** Beyond per-event capture, two extra hooks mark *reasoning boundaries* automatically. On `PreCompact`, Task Journal reads the transcript JSONL tail (entries newer than the active task's last event) and enqueues anything the synchronous hooks missed before the compact — then drops a marker decision so the post-compact agent sees a clear cut. A `/rewind`-prefixed prompt appends a single correction event so pack readers see where the user rolled back. No mass-rejection of prior events — the boundary is a sentinel, not a rewrite. @@ -169,13 +170,14 @@ task-journal pack tj-x9rz1f --mode full | Command | What it does | |---------|--------------| -| `create [--goal "..."]` | Open a task with optional goal | +| `create <title> [--goal "..."] [--modules a,b]` | Open a task with optional goal and modules | +| `module list [--json] \| show <id> \| save <id> [--name] [--description] [--path P] [--term T] [--state] [--status] [--merged-into] \| link <task> [--add M] [--remove M] \| candidates [--limit N]` | The project chronicle: module map, module pages, creating and linking modules, tasks without a module | | `goal <id> "..."` | Set or replace a task's goal | | `event <id> --type X --text Y [--suggested] [--session S] [--origin O]` | Append a typed event | | `state [--session S]` | The session's active task, counts and latest entries as JSON (what the mod reads) | | `event-correct --corrects <eid> --task <id> --text "..."` | Correct an earlier event | | `external <id> "..."` | Append an external reference (URL, ticket, linked task) | -| `close <id> --outcome "..." --outcome-tag done\|abandoned\|superseded` | Close with outcome | +| `close <id> --outcome "..." --outcome-tag done\|abandoned\|superseded [--module-note module=text]` | Close with outcome (and a line of each module's history) | | `reopen <id> --reason "..."` | Reopen a closed task | | `pack <id> --mode compact\|full` | Render a resume pack | | `events list [--limit N]` | List recent events | @@ -198,7 +200,7 @@ task-journal pack tj-x9rz1f --mode full ## MCP tools -The MCP server exposes seven tools to Claude Code, Codex and any MCP client: +The MCP server exposes these tools to Claude Code, Codex and any MCP client: | Tool | Purpose | |------|---------| @@ -209,6 +211,14 @@ The MCP server exposes seven tools to Claude Code, Codex and any MCP client: | `task_pack` | Render a resume pack | | `task_search` | Search tasks: full text, or `status="open"` with no query to list them | | `task_check` | Score a task's completeness and list its gaps | +| `module_list` | The module map and what the chronicle is missing | +| `module_page` | A module's page: state, decisions, rejections, constraints, history | +| `module_save` | Create or update a module (partial) | +| `module_link` | Link tasks to modules, many at once | +| `module_backfill_candidates` | Tasks without a module, with suggestions, for sorting old work | + +`task_create` takes optional `modules` and suggests some when they're missing; +`task_close` takes optional `module_notes`; `task_search` takes an optional `module`. The write tools take an optional `session_id`; Claude Code (through the mod) and Codex (through each call's metadata) fill it in, so every session keeps its own diff --git a/crates/tj-mcp/tests/skill_doc.rs b/crates/tj-mcp/tests/skill_doc.rs index d18bbfc..76fb65b 100644 --- a/crates/tj-mcp/tests/skill_doc.rs +++ b/crates/tj-mcp/tests/skill_doc.rs @@ -92,8 +92,17 @@ fn map_command_exists_and_asks_the_user_first() { .unwrap_or_else(|e| panic!("map command missing at {}: {e}", path.display())) .replace("\r\n", "\n"); - assert!(s.starts_with("---\n") && s.contains("\ndescription:"), "frontmatter"); - for needle in ["module_list", "module_save", "module_backfill_candidates", "module_link", "confirm"] { + assert!( + s.starts_with("---\n") && s.contains("\ndescription:"), + "frontmatter" + ); + for needle in [ + "module_list", + "module_save", + "module_backfill_candidates", + "module_link", + "confirm", + ] { assert!(s.contains(needle), "map command must mention {needle}"); } // Saving comes only after the user's answer. diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index a2b653e..47b32a8 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.30.0", + "version": "0.31.0", "description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.", "author": { "name": "Mher Shahinyan" diff --git a/plugin/package.json b/plugin/package.json index 6aba016..3eaec0d 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.30.0", + "version": "0.31.0", "description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.", "author": { "name": "Mher Shahinyan", From 970082eca2a18baed5c565a2eb9b1789c69de857 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:14:34 +0400 Subject: [PATCH 11/23] fix(mod): refresh the status line after module_link and module_save --- plugin/hooks/journal.test.ts | 10 ++++++++++ plugin/hooks/journal.ts | 4 ++++ plugin/hooks/register.ts | 8 +++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/plugin/hooks/journal.test.ts b/plugin/hooks/journal.test.ts index f85f215..a385f6a 100644 --- a/plugin/hooks/journal.test.ts +++ b/plugin/hooks/journal.test.ts @@ -9,6 +9,8 @@ import { sectionText, statusText, transcriptExcerpt, + REFRESH_TOOLS, + WRITE_TOOLS, type JournalState, } from './journal' @@ -123,6 +125,14 @@ describe('chronicle', () => { }) }) +describe('REFRESH_TOOLS', () => { + test('linking or saving a module refreshes the status line; only journal writes get a session id', () => { + for (const t of ['module_link', 'module_save', 'task_create', 'task_close']) expect(REFRESH_TOOLS.has(t)).toBe(true) + expect(WRITE_TOOLS.has('module_link')).toBe(false) + expect(REFRESH_TOOLS.has('module_page')).toBe(false) + }) +}) + describe('journalTool', () => { test('matches the plugin server and a hand-wired one', () => { expect(journalTool('mcp__plugin_task-journal_task-journal__event_add')).toBe('event_add') diff --git a/plugin/hooks/journal.ts b/plugin/hooks/journal.ts index a464796..1d8ae2c 100644 --- a/plugin/hooks/journal.ts +++ b/plugin/hooks/journal.ts @@ -37,6 +37,10 @@ const JOURNAL_TOOL = /^mcp__(?:plugin_task-journal_)?task-journal__([a-z_]+)$/ export const WRITE_TOOLS = new Set(['task_create', 'event_add', 'artifact_add', 'task_close']) +// After these the status line is re-read: the journal writes, and the module +// tools that change which modules a task shows. +export const REFRESH_TOOLS = new Set([...WRITE_TOOLS, 'module_link', 'module_save']) + export function journalTool(tool: string): string | undefined { return JOURNAL_TOOL.exec(tool)?.[1] } diff --git a/plugin/hooks/register.ts b/plugin/hooks/register.ts index d9c41e0..d1f6b17 100644 --- a/plugin/hooks/register.ts +++ b/plugin/hooks/register.ts @@ -26,6 +26,7 @@ import { nudgeText, parseDistill, parseState, + REFRESH_TOOLS, sectionText, statusText, transcriptExcerpt, @@ -288,7 +289,12 @@ export const register: Register = (on, options) => { const session = await $.session.id() const call = WRITE_TOOLS.has(name) && args.session_id === undefined ? { ...e, session_id: session } : e const ran = await next(call) - if (ran.deny !== undefined || ran.isError === true || !WRITE_TOOLS.has(name)) return ran + if (ran.deny !== undefined || ran.isError === true || !REFRESH_TOOLS.has(name)) return ran + if (!WRITE_TOOLS.has(name)) { + await refresh($) + + return ran + } // A new task, or closing the named one, moves the pin; logging to // another task does not. From 702ab61ca53e1affa0e689c3b63a40f8621294cb Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:30:14 +0400 Subject: [PATCH 12/23] fix(core): module page keeps every section on real-sized entries --- crates/tj-core/src/modules.rs | 109 +++++++++++++++++++++++++++++----- 1 file changed, 93 insertions(+), 16 deletions(-) diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index 2058bca..eb04b70 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -600,8 +600,14 @@ pub fn backfill_candidates( /// A module page is read like a full task pack: same budget. const PAGE_BUDGET: usize = 32 * 1024; -/// Entries each of decisions, rejections and constraints lists in full. -const SECTION_ITEMS: usize = 40; +/// Entries the decisions section lists; rejections and constraints list fewer. +/// With one-line entries the three stay well inside the budget, so the +/// history below them always shows. +const DECISION_ITEMS: usize = 20; +const OTHER_ITEMS: usize = 15; + +/// Longest entry on a page: the start of what was recorded, on one line. +const ENTRY_CHARS: usize = 200; /// Closed tasks listed in full before older history shrinks to one line each. const FULL_HISTORY: usize = 30; @@ -683,13 +689,25 @@ fn short(text: &str, max: usize) -> String { format!("{cut}…") } -fn push_section(out: &mut String, title: &str, items: &[String]) { +/// `text` as one line of at most `max` chars: line breaks and Markdown +/// heading marks folded away, so an entry never breaks the page's structure. +fn one_line(text: &str, max: usize) -> String { + let flat = text + .split_whitespace() + .filter(|w| !w.chars().all(|c| c == '#')) + .collect::<Vec<_>>() + .join(" "); + + short(&flat, max) +} + +fn push_section(out: &mut String, title: &str, items: &[String], limit: usize) { out.push_str(&format!("\n## {title}\n")); if items.is_empty() { out.push_str("- (none)\n"); } - let shown = items.len().min(SECTION_ITEMS); + let shown = items.len().min(limit); for item in &items[..shown] { out.push_str(item); out.push('\n'); @@ -760,26 +778,33 @@ pub fn page(conn: &Connection, project_hash: &str, module_id: &str) -> anyhow::R } let (mut decisions, mut rejected, mut constraints) = (Vec::new(), Vec::new(), Vec::new()); + let entry = |text: &str, marker: &str, task: &str| { + format!("- {}{marker} ({task})", one_line(text, ENTRY_CHARS)) + }; for r in &rows { for d in crate::pack::active_decisions(conn, &r.task_id)? { - decisions.push(format!("- {}{} ({})", d.text, d.marker(), r.task_id)); + if !crate::pack::is_noise(&d.text) { + decisions.push(entry(&d.text, d.marker(), &r.task_id)); + } } for x in crate::pack::rejections(conn, &r.task_id)? { - rejected.push(format!("- {}{} ({})", x.text, x.marker(), r.task_id)); + if !crate::pack::is_noise(&x.text) { + rejected.push(entry(&x.text, x.marker(), &r.task_id)); + } } for c in texts_of(conn, &r.task_id, "constraint")? { - constraints.push(format!("- {c} ({})", r.task_id)); + constraints.push(entry(&c, "", &r.task_id)); } } - push_section(&mut out, "Active decisions", &decisions); - push_section(&mut out, "Rejected", &rejected); - push_section(&mut out, "Constraints", &constraints); + push_section(&mut out, "Active decisions", &decisions, DECISION_ITEMS); + push_section(&mut out, "Rejected", &rejected, OTHER_ITEMS); + push_section(&mut out, "Constraints", &constraints, OTHER_ITEMS); let open: Vec<String> = open .iter() - .map(|r| format!("- {} {}", r.task_id, r.title)) + .map(|r| format!("- {} {}", r.task_id, one_line(&r.title, ENTRY_CHARS))) .collect(); - push_section(&mut out, "Open tasks", &open); + push_section(&mut out, "Open tasks", &open, OTHER_ITEMS); out.push_str("\n## History\n"); if closed.is_empty() { @@ -789,20 +814,21 @@ pub fn page(conn: &Connection, project_hash: &str, module_id: &str) -> anyhow::R let outcome = r.outcome.as_deref().unwrap_or("(no outcome)"); if i < FULL_HISTORY { out.push_str(&format!( - "- {} · {} · {} — {outcome}\n", + "- {} · {} · {} — {}\n", day(&r.at), r.task_id, - r.title + one_line(&r.title, ENTRY_CHARS), + one_line(outcome, ENTRY_CHARS) )); if let Some(note) = &r.note { - out.push_str(&format!(" ↳ {note}\n")); + out.push_str(&format!(" ↳ {}\n", one_line(note, ENTRY_CHARS))); } } else { out.push_str(&format!( "- {} · {} · {}\n", day(&r.at), r.task_id, - short(outcome, SHORT_OUTCOME) + one_line(outcome, SHORT_OUTCOME) )); } } @@ -1308,6 +1334,57 @@ mod tests { assert!(p.contains("more"), "no sign that entries were left out"); } + #[test] + fn page_of_real_sized_entries_keeps_every_section() { + // Sizes from the real journal: ~600 B decisions, 2 KB multi-line + // rejections with their own Markdown headings, a few constraints. + let mut events = vec![named("real", "Real")]; + for i in 0..10 { + let id = format!("tj-{i:02}"); + events.push(open_task(&id, &["real"])); + for d in 0..6 { + events.push(said( + &id, + EventType::Decision, + &format!("decision {d}: {}", "x".repeat(600)), + )); + } + let rejection = format!( + "## Summary\n{}\n## 1. Details\n{}", + "r".repeat(900), + "q".repeat(900) + ); + events.push(said(&id, EventType::Rejection, &rejection)); + events.push(said(&id, EventType::Constraint, &"c".repeat(400))); + if i > 0 { + events.push(close_task( + &id, + serde_json::json!({"outcome": "o".repeat(300)}), + )); + } + } + let (_d, conn) = journal(&events); + + let p = page(&conn, "p", "real").unwrap(); + + assert!(p.len() <= PAGE_BUDGET, "{}", p.len()); + for section in [ + "## Constraints", + "## Open tasks", + "## History", + "tj-00", + "tj-09", + ] { + assert!(p.contains(section), "missing {section}"); + } + // Each entry is one line: no heading smuggled in from an event's text. + assert_eq!(p.matches("## Summary").count(), 0, "{p}"); + assert!( + p.lines().all(|l| l.chars().count() <= 400), + "a line runs too long" + ); + } + #[test] fn unknown_module_page_is_an_error() { let (_d, conn) = mapped(); From 43d1ae3dc657b30c761e91f17061d1511f609c13 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:31:19 +0400 Subject: [PATCH 13/23] fix: chronicle gaps put actionable ones first; backfill pages with offset; leftovers end in a catch-all module --- crates/tj-cli/src/main.rs | 6 +++-- crates/tj-core/src/archive.rs | 38 ++++++++++++++++------------- crates/tj-core/src/modules.rs | 20 +++++++++------ crates/tj-mcp/src/main.rs | 16 +++++++++--- crates/tj-mcp/tests/skill_doc.rs | 3 +++ plugin/commands/map.md | 9 ++++--- plugin/hooks/journal.ts | 2 +- plugin/skills/task-journal/SKILL.md | 13 ++++++---- 8 files changed, 67 insertions(+), 40 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 0c04f21..9dd5376 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -275,9 +275,9 @@ fn run_module(action: ModuleCmd) -> Result<()> { ))])?; println!("linked {task_id}"); } - ModuleCmd::Candidates { limit } => { + ModuleCmd::Candidates { limit, offset } => { let (total, candidates) = with_chronicle(|conn, hash| { - tj_core::modules::backfill_candidates(conn, hash, limit) + tj_core::modules::backfill_candidates(conn, hash, limit, offset) })?; let out = serde_json::json!({ "total_unlinked": total, "candidates": candidates }); println!("{}", serde_json::to_string(&out)?); @@ -1569,6 +1569,8 @@ enum ModuleCmd { Candidates { #[arg(long, default_value_t = 20)] limit: usize, + #[arg(long, default_value_t = 0)] + offset: usize, }, } diff --git a/crates/tj-core/src/archive.rs b/crates/tj-core/src/archive.rs index b67852f..bb8132b 100644 --- a/crates/tj-core/src/archive.rs +++ b/crates/tj-core/src/archive.rs @@ -32,7 +32,7 @@ impl Gap { ), Gap::UnlinkedTasks { count } => format!( "{count} task(s) belong to no module — sort them with module_backfill_candidates, \ - confirm with the user, then module_link" + confirm with the user, then module_link (leftovers go to a catch-all module)" ), Gap::StaleModule { module_id, @@ -79,14 +79,16 @@ pub fn gaps( return Ok(vec![Gap::NoMap { tasks }]); } + // What the agent can act on right now comes first: its own task, then a + // module whose state lags. Sorting old tasks needs the user, so it waits. let mut out = Vec::new(); - let unlinked = count( - "SELECT COUNT(*) FROM tasks t WHERE t.project_hash = ?1 - AND NOT EXISTS (SELECT 1 FROM task_modules tm WHERE tm.task_id = t.task_id)", - )?; - if unlinked > 0 { - out.push(Gap::UnlinkedTasks { count: unlinked }); + if let Some(task_id) = active_task { + if crate::modules::modules_of_task(conn, task_id)?.is_empty() { + out.push(Gap::TaskWithoutModule { + task_id: task_id.to_string(), + }); + } } let mut stmt = conn.prepare( @@ -106,12 +108,12 @@ pub fn gaps( }); } - if let Some(task_id) = active_task { - if crate::modules::modules_of_task(conn, task_id)?.is_empty() { - out.push(Gap::TaskWithoutModule { - task_id: task_id.to_string(), - }); - } + let unlinked = count( + "SELECT COUNT(*) FROM tasks t WHERE t.project_hash = ?1 + AND NOT EXISTS (SELECT 1 FROM task_modules tm WHERE tm.task_id = t.task_id)", + )?; + if unlinked > 0 { + out.push(Gap::UnlinkedTasks { count: unlinked }); } Ok(out) @@ -168,15 +170,17 @@ mod tests { assert_eq!( g, + // What the agent can act on now comes first; sorting old + // tasks waits behind it. vec![ - Gap::UnlinkedTasks { count: 1 }, + Gap::TaskWithoutModule { + task_id: "tj-b".into() + }, Gap::StaleModule { module_id: "stars".into(), closed_since: 1 }, - Gap::TaskWithoutModule { - task_id: "tj-b".into() - }, + Gap::UnlinkedTasks { count: 1 }, ] ); } diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index eb04b70..b32d777 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -556,11 +556,12 @@ pub struct Candidate { const UNLINKED: &str = "FROM tasks t WHERE t.project_hash = ?1 AND NOT EXISTS (SELECT 1 FROM task_modules tm WHERE tm.task_id = t.task_id)"; -/// The newest `limit` tasks without a module, and how many there are in all. +/// A page of tasks without a module, newest first, and how many there are in all. pub fn backfill_candidates( conn: &Connection, project_hash: &str, limit: usize, + offset: usize, ) -> anyhow::Result<(i64, Vec<Candidate>)> { let total: i64 = conn.query_row( &format!("SELECT COUNT(*) {UNLINKED}"), @@ -568,12 +569,13 @@ pub fn backfill_candidates( |r| r.get(0), )?; let mut stmt = conn.prepare(&format!( - "SELECT t.task_id, t.title, t.status {UNLINKED} ORDER BY t.last_event_at DESC LIMIT ?2" + "SELECT t.task_id, t.title, t.status {UNLINKED} ORDER BY t.last_event_at DESC, t.task_id LIMIT ?2 OFFSET ?3" ))?; let rows: Vec<(String, String, String)> = stmt - .query_map(rusqlite::params![project_hash, limit as i64], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - })? + .query_map( + rusqlite::params![project_hash, limit as i64, offset as i64], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + )? .collect::<Result<_, _>>()?; let mut out = Vec::new(); @@ -1180,7 +1182,7 @@ mod tests { old.meta["goal"] = serde_json::json!("Speed up the star feed"); let (_d, conn) = journal(&[stars, old, open_task("tj-linked", &["stars"])]); - let (total, page) = backfill_candidates(&conn, "p", 10).unwrap(); + let (total, page) = backfill_candidates(&conn, "p", 10, 0).unwrap(); assert_eq!(total, 1); assert_eq!(page.len(), 1); @@ -1197,9 +1199,11 @@ mod tests { open_task("tj-3", &[]), ]); - let (total, page) = backfill_candidates(&conn, "p", 2).unwrap(); + let (total, first) = backfill_candidates(&conn, "p", 2, 0).unwrap(); + let (_, rest) = backfill_candidates(&conn, "p", 2, 2).unwrap(); - assert_eq!((total, page.len()), (3, 2)); + assert_eq!((total, first.len(), rest.len()), (3, 2, 1)); + assert!(first.iter().all(|c| c.task_id != rest[0].task_id)); } fn said(task: &str, kind: EventType, text: &str) -> Event { diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 9a422b2..1499f37 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -432,6 +432,8 @@ pub struct ModulePageResult { pub struct BackfillParams { /// Tasks per page, newest first (default 20, at most 100). pub limit: Option<usize>, + /// Tasks to skip: page on past the ones you left for later. + pub offset: Option<usize>, } #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct BackfillResult { @@ -1366,7 +1368,7 @@ impl TaskJournalServer { #[tool( name = "module_backfill_candidates", - description = "Tasks that belong to no module, newest first: id, title, status, goal, outcome, files, and the modules the journal suggests for each. Sort them, confirm with the user, then module_link. `total_unlinked` counts them all; page with `limit`." + description = "Tasks that belong to no module, newest first: id, title, status, goal, outcome, files, and the modules the journal suggests for each. Sort them, confirm with the user, then module_link. `total_unlinked` counts them all; page with `limit` and `offset`." )] async fn module_backfill_candidates( &self, @@ -1383,7 +1385,12 @@ impl TaskJournalServer { let limit = p.limit.unwrap_or(20).min(100); let (total_unlinked, candidates) = with_state(|conn, project_hash| { - tj_core::modules::backfill_candidates(conn, project_hash, limit) + tj_core::modules::backfill_candidates( + conn, + project_hash, + limit, + p.offset.unwrap_or(0), + ) })?; Ok(Json(BackfillResult { @@ -2748,7 +2755,10 @@ mod tests { assert!(created.chronicle.is_some()); let candidates = server - .module_backfill_candidates(Parameters(BackfillParams { limit: Some(100) })) + .module_backfill_candidates(Parameters(BackfillParams { + limit: Some(100), + offset: None, + })) .await .unwrap() .0; diff --git a/crates/tj-mcp/tests/skill_doc.rs b/crates/tj-mcp/tests/skill_doc.rs index 76fb65b..27d3920 100644 --- a/crates/tj-mcp/tests/skill_doc.rs +++ b/crates/tj-mcp/tests/skill_doc.rs @@ -80,6 +80,8 @@ fn skill_teaches_the_chronicle() { "module_notes", "📚 Chronicle", "confirm", + "catch-all", + "offset", ] { assert!(s.contains(needle), "skill must mention {needle}"); } @@ -102,6 +104,7 @@ fn map_command_exists_and_asks_the_user_first() { "module_backfill_candidates", "module_link", "confirm", + "catch-all", ] { assert!(s.contains(needle), "map command must mention {needle}"); } diff --git a/plugin/commands/map.md b/plugin/commands/map.md index 03443a4..1d2d023 100644 --- a/plugin/commands/map.md +++ b/plugin/commands/map.md @@ -15,11 +15,12 @@ Focus (optional): $ARGUMENTS prefixes) and `hints.terms` (words people use for it). Ask the user to confirm or correct the list, and wait for the answer. 4. After the user's yes, `module_save` each confirmed module. -5. Call `module_backfill_candidates(limit=50)`, page by page. Assign each task to modules - using its title, goal, outcome, files and the suggestions; list the doubtful ones - separately. +5. Call `module_backfill_candidates(limit=50)`, page by page (`offset` steps past a page). + Assign each task to modules using its title, goal, outcome, files and the suggestions; + list the doubtful ones separately. 6. Show the assignment to the user and ask them to confirm it. After a yes, make one - `module_link` call per page. + `module_link` call per page. Tasks that fit no module go to a catch-all module + (`other`, "Other") once the user agrees, so no task is left without a module. 7. For every module with tasks, read `module_page` and write its first `state` with `module_save(module_id, state=...)`: a few sentences on how it works now. diff --git a/plugin/hooks/journal.ts b/plugin/hooks/journal.ts index 1d8ae2c..4ccba33 100644 --- a/plugin/hooks/journal.ts +++ b/plugin/hooks/journal.ts @@ -106,7 +106,7 @@ const GAP_TEXT: Record<string, (g: ArchiveGap) => string> = { no_map: g => `no module map yet (${g.tasks} tasks) — map the project with /task-journal:map and confirm the modules with the user`, unlinked_tasks: g => - `${g.count} task(s) belong to no module — sort them with module_backfill_candidates, confirm with the user, then module_link`, + `${g.count} task(s) belong to no module — sort them with module_backfill_candidates, confirm with the user, then module_link (leftovers go to a catch-all module)`, stale_module: g => `module ${g.module_id}: ${g.closed_since} closed task(s) not reflected in its state — read module_page and rewrite it with module_save(state=...)`, task_without_module: g => `the active task ${g.task_id} belongs to no module — link it with module_link (module_list shows the map)`, diff --git a/plugin/skills/task-journal/SKILL.md b/plugin/skills/task-journal/SKILL.md index d20a85c..d1fcec1 100644 --- a/plugin/skills/task-journal/SKILL.md +++ b/plugin/skills/task-journal/SKILL.md @@ -135,9 +135,12 @@ system before touching it. `hints.paths` (code path prefixes) and `hints.terms` (the words people use for it). 4. Show the list to the user and **confirm** it; adjust from the answer. 5. `module_save` each confirmed module. -6. `module_backfill_candidates(limit=50)`, page by page: assign each old task to modules by - its title, goal, outcome, files and the suggestions; list the doubtful ones separately. -7. Show the assignment to the user, **confirm**, then one `module_link` call per page. +6. `module_backfill_candidates(limit=50)`, page by page (`offset` steps past a page): assign + each old task to modules by its title, goal, outcome, files and the suggestions; list the + doubtful ones separately. +7. Show the assignment to the user, **confirm**, then one `module_link` call per page. Tasks + that fit no module go to a catch-all module (`other`, "Other") once the user agrees, so + the chronicle has no loose ends. 8. For each module with tasks, read its `module_page` and write its first `state`. Chronicle tools: @@ -147,8 +150,8 @@ Chronicle tools: - `module_save(module_id, name?, description?, hints?{paths, terms}, state?, status?, merged_into?)` — partial update; a new module needs `name`. `status`: active | retired | merged. - `module_link(links=[{task_id, add?: [...], remove?: [...]}])` — many tasks at once. -- `module_backfill_candidates(limit?)` → `total_unlinked`, `candidates` (id, title, goal, - outcome, files, suggestions). +- `module_backfill_candidates(limit?, offset?)` → `total_unlinked`, `candidates` (id, title, + goal, outcome, files, suggestions). ## The 5 MCP tools (exact params) From cee06eac855ca9b733dc677d6dd2dcb61c6e7dbd Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:31:35 +0400 Subject: [PATCH 14/23] fix(cli): keep module lines out of the JSON export too --- crates/tj-cli/src/main.rs | 8 +++----- crates/tj-cli/tests/cli.rs | 8 +++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 9dd5376..6347b6c 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -3610,11 +3610,9 @@ runs in the background and won't block you; it only fills gaps and never closes } let mut all_events = read_events_lenient(&events_path, "export")?; - // Module lines are the project's map, not a task: only the full - // JSON dump carries them. - if format != "json" { - all_events.retain(|e| !e.is_module()); - } + // Module lines are the project's map, not a task: hosts group an + // export by task id, so they stay out of every format. + all_events.retain(|e| !e.is_module()); // Filter to specific task if requested. let events: Vec<&tj_core::event::Event> = if let Some(ref tid) = task { diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index e7ce5e7..992caab 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -8506,9 +8506,9 @@ fn event_on_an_unknown_task_fails_and_writes_nothing() { } /// A `module` line (the project chronicle) describes the project's map, not a -/// task: task views skip it, the full JSON dump keeps it. +/// task: task views and every export format skip it. #[test] -fn module_lines_stay_out_of_task_views_but_not_json_export() { +fn module_lines_stay_out_of_task_views_and_exports() { let xdg = assert_fs::TempDir::new().unwrap(); let proj = assert_fs::TempDir::new().unwrap(); let tj = || { @@ -8546,10 +8546,12 @@ fn module_lines_stay_out_of_task_views_but_not_json_export() { .success() .stdout(contains("mod:stars").not()); } + // JSON export too: hosts such as the Loom board group it by task id, and + // a module line would turn into a phantom task. The JSONL is the full copy. tj().args(["export", "--format", "json"]) .assert() .success() - .stdout(contains("mod:stars")); + .stdout(contains("mod:stars").not()); } /// A `task-journal` command run in its own data home and project dir. From cdf3a426cd06097dad6d24c5b8f31e87dc90134c Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:32:12 +0400 Subject: [PATCH 15/23] fix(mod): never bring up a chronicle gap the session has already seen --- plugin/hooks/journal.test.ts | 28 ++++++++++++++++++++++++++++ plugin/hooks/journal.ts | 31 +++++++++++++++++++++---------- plugin/hooks/register.ts | 17 +++++++++-------- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/plugin/hooks/journal.test.ts b/plugin/hooks/journal.test.ts index a385f6a..4f7f389 100644 --- a/plugin/hooks/journal.test.ts +++ b/plugin/hooks/journal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'claude-code/testing' import { + chronicleKeys, chronicleNudge, journalTool, nudgeText, @@ -119,6 +120,33 @@ describe('chronicle', () => { ) }) + test('a gap seen once in a session is not brought up again', () => { + // The session starts with unlinked tasks; the new task has no module; + // once it is linked, the start gap must not come back. + const start = withGaps([{ kind: 'unlinked_tasks', count: 4 }]) + const seen = new Set(chronicleKeys(start)) + expect(chronicleNudge(start, seen)).toBe(null) + + const created = withGaps([ + { kind: 'task_without_module', task_id: 'tj-a' }, + { kind: 'unlinked_tasks', count: 5 }, + ]) + const n = chronicleNudge(created, seen) + expect(n?.key).toBe('task_without_module:tj-a') + seen.add(n!.key) + + const linked = withGaps([{ kind: 'unlinked_tasks', count: 4 }]) + expect(chronicleNudge(linked, seen)).toBe(null) + }) + + test('the first unseen gap is brought up even behind a seen one', () => { + const s = withGaps([ + { kind: 'stale_module', module_id: 'stars', closed_since: 1 }, + { kind: 'stale_module', module_id: 'auth', closed_since: 1 }, + ]) + expect(chronicleNudge(s, new Set(['stale_module:stars']))?.key).toBe('stale_module:auth') + }) + test('no gaps, or only unknown ones, is no nudge', () => { expect(chronicleNudge(withGaps([]))).toBe(null) expect(chronicleNudge(withGaps([{ kind: 'from_the_future' }]))).toBe(null) diff --git a/plugin/hooks/journal.ts b/plugin/hooks/journal.ts index 4ccba33..4677cae 100644 --- a/plugin/hooks/journal.ts +++ b/plugin/hooks/journal.ts @@ -116,21 +116,32 @@ const GAP_TEXT: Record<string, (g: ArchiveGap) => string> = { // so a gap whose count moved is not new. const GAP_SUBJECT: Record<string, string> = { stale_module: 'module_id', task_without_module: 'task_id' } +function gapKey(gap: ArchiveGap): string { + const subject = GAP_SUBJECT[gap.kind] + + return subject === undefined ? gap.kind : `${gap.kind}:${String(gap[subject])}` +} + +/** Keys of the gaps a state names: what the session start already showed. */ +export function chronicleKeys(state: JournalState): string[] { + return state.archive.filter(g => GAP_TEXT[g.kind] !== undefined).map(gapKey) +} + /** - * The chronicle gap to bring up, keyed so the same gap is not brought up - * twice. The session's own task without a module comes first: it is the - * one gap the agent can close right now. + * The chronicle gap to bring up, if one is new to this session (`seen` + * holds the keys already shown). The session's own task without a module + * comes first: it is the one gap the agent can close right now. */ -export function chronicleNudge(state: JournalState): { key: string; text: string } | null { - const own = state.archive.find(g => g.kind === 'task_without_module') - const gap = own ?? state.archive.find(g => GAP_TEXT[g.kind] !== undefined) +export function chronicleNudge( + state: JournalState, + seen: ReadonlySet<string> = new Set(), +): { key: string; text: string } | null { + const fresh = state.archive.filter(g => GAP_TEXT[g.kind] !== undefined && !seen.has(gapKey(g))) + const gap = fresh.find(g => g.kind === 'task_without_module') ?? fresh[0] const describe = gap === undefined ? undefined : GAP_TEXT[gap.kind] if (gap === undefined || describe === undefined) return null - const subject = GAP_SUBJECT[gap.kind] - const key = subject === undefined ? gap.kind : `${gap.kind}:${String(gap[subject])}` - - return { key, text: `📚 Chronicle: ${describe(gap)}. Close this gap once the current work is done.` } + return { key: gapKey(gap), text: `📚 Chronicle: ${describe(gap)}. Close this gap once the current work is done.` } } /** diff --git a/plugin/hooks/register.ts b/plugin/hooks/register.ts index d1f6b17..97025bf 100644 --- a/plugin/hooks/register.ts +++ b/plugin/hooks/register.ts @@ -19,6 +19,7 @@ import type { EngineInterface, Register } from 'claude-code' import { + chronicleKeys, chronicleNudge, compactInstruction, distillPrompt, @@ -66,9 +67,9 @@ const mod = { pinned: null as string | null, turnsSinceEntry: 0, workSinceEntry: 0, - // The chronicle gap last brought up. Seeded at the session's start with - // the gap the SessionStart hook shows, so it is not repeated. - chronicleKey: null as string | null, + // Chronicle gaps this session has been told about. Seeded at its start + // with every gap the SessionStart hook showed, so none is repeated. + chronicleSeen: new Set<string>(), } type CliResult = @@ -131,7 +132,7 @@ async function refresh($: EngineInterface): Promise<void> { if (mod.session !== session) { mod.pinned = null - mod.chronicleKey = chronicleNudge(state)?.key ?? null + mod.chronicleSeen = new Set(chronicleKeys(state)) } mod.session = session mod.state = state @@ -229,7 +230,7 @@ export const register: Register = (on, options) => { mod.retryAt = 0 mod.turnsSinceEntry = 0 mod.workSinceEntry = 0 - mod.chronicleKey = null + mod.chronicleSeen = new Set() return next(e) }) @@ -258,9 +259,9 @@ export const register: Register = (on, options) => { // Both reminders ride in the prompt's context, never in the system // prompt: a change there would re-send the conversation uncached. const added: string[] = [] - const chronicle = chronicleNudge(state) - if (chronicle !== null && chronicle.key !== mod.chronicleKey) { - mod.chronicleKey = chronicle.key + const chronicle = chronicleNudge(state, mod.chronicleSeen) + if (chronicle !== null) { + mod.chronicleSeen.add(chronicle.key) added.push(chronicle.text) } From 0a8c2c851e7b4ca55e0adde88085ba980fa485ab Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:33:30 +0400 Subject: [PATCH 16/23] fix: a git worktree is not asked to map the project again --- crates/tj-cli/src/main.rs | 9 +++++++-- crates/tj-cli/tests/cli.rs | 30 ++++++++++++++++++++++++++++++ crates/tj-core/src/archive.rs | 29 +++++++++++++++++++++++++++++ crates/tj-core/src/project_hash.rs | 8 ++++++++ crates/tj-mcp/src/main.rs | 10 ++++++++-- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 6347b6c..241d551 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -164,7 +164,10 @@ fn run_module(action: ModuleCmd) -> Result<()> { let (modules, gaps) = with_chronicle(|conn, hash| { Ok(( tj_core::modules::list(conn, hash)?, - tj_core::archive::gaps(conn, hash, None)?, + tj_core::archive::for_dir( + tj_core::archive::gaps(conn, hash, None)?, + &std::env::current_dir()?, + ), )) })?; if json { @@ -2006,7 +2009,7 @@ fn real_main() -> Result<()> { let session = session.or_else(tj_core::session_id::session_id_from_env); // No journal yet: an empty state, and no state DB created for it. - let state = if events_path.exists() { + let mut state = if events_path.exists() { let conn = tj_core::db::open(&state_path)?; tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; tj_core::session_state::session_state( @@ -2025,6 +2028,7 @@ fn real_main() -> Result<()> { } }; + state.archive = tj_core::archive::for_dir(state.archive, &cwd); println!("{}", serde_json::to_string(&state)?); } Commands::ArtifactAdd { @@ -2979,6 +2983,7 @@ runs in the background and won't block you; it only fills gaps and never closes // Advice only: it never fails the session start. if let Some(line) = tj_core::archive::gaps(&conn, &project_hash, None) .ok() + .map(|gaps| tj_core::archive::for_dir(gaps, &cwd)) .and_then(|gaps| tj_core::archive::headline(&gaps)) { bundle.push_str(&line); diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 992caab..6a8deb3 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -8738,3 +8738,33 @@ fn state_carries_archive_gaps() { .success() .stdout(contains(r#""kind":"no_map""#)); } + +#[test] +fn a_worktree_session_is_not_asked_to_map_the_project() { + let (xdg, proj) = ( + assert_fs::TempDir::new().unwrap(), + assert_fs::TempDir::new().unwrap(), + ); + std::fs::write( + proj.path().join(".git"), + "gitdir: /elsewhere/.git/worktrees/x\n", + ) + .unwrap(); + let tj = chronicle_cli(&xdg, &proj); + tj().args(["create", "Work in a worktree"]) + .assert() + .success(); + + let mut hook = tj(); + hook.args(["ingest-hook", "--kind", "SessionStart", "--text", ""]); + let out: serde_json::Value = serde_json::from_str(&stdout_of(hook)).unwrap(); + let ctx = out["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap(); + + assert!(!ctx.contains("no module map"), "{ctx}"); + tj().args(["state"]) + .assert() + .success() + .stdout(contains("no_map").not()); +} diff --git a/crates/tj-core/src/archive.rs b/crates/tj-core/src/archive.rs index bb8132b..538d426 100644 --- a/crates/tj-core/src/archive.rs +++ b/crates/tj-core/src/archive.rs @@ -49,6 +49,20 @@ impl Gap { } } +/// `gaps` as seen from `dir`. A linked git worktree keeps a journal of its +/// own while the map lives in the main checkout, so it is not asked to map. +// ponytail: hides NoMap in worktrees; reading the main checkout's chronicle +// from a worktree is the real fix. +pub fn for_dir(gaps: Vec<Gap>, dir: &std::path::Path) -> Vec<Gap> { + if !crate::project_hash::is_linked_worktree(dir) { + return gaps; + } + + gaps.into_iter() + .filter(|g| !matches!(g, Gap::NoMap { .. })) + .collect() +} + /// The line a session start or a tool reply shows: the most important gap. pub fn headline(gaps: &[Gap]) -> Option<String> { let first = gaps.first()?; @@ -228,6 +242,21 @@ mod tests { assert!(headline(&[]).is_none()); } + #[test] + fn a_linked_worktree_is_not_asked_to_map_the_project() { + // A worktree (its root holds a `.git` file) has its own journal; the + // map lives in the main checkout, so asking to map again is wrong. + let main = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(main.path().join(".git")).unwrap(); + std::fs::write(main.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let worktree = tempfile::TempDir::new().unwrap(); + std::fs::write(worktree.path().join(".git"), "gitdir: /elsewhere\n").unwrap(); + let gaps = vec![Gap::NoMap { tasks: 2 }]; + + assert_eq!(for_dir(gaps.clone(), main.path()), gaps); + assert!(for_dir(gaps, worktree.path()).is_empty()); + } + #[test] fn gaps_serialize_with_a_kind_tag() { let json = serde_json::to_string(&Gap::UnlinkedTasks { count: 2 }).unwrap(); diff --git a/crates/tj-core/src/project_hash.rs b/crates/tj-core/src/project_hash.rs index 80aae36..f57e5b9 100644 --- a/crates/tj-core/src/project_hash.rs +++ b/crates/tj-core/src/project_hash.rs @@ -34,6 +34,14 @@ pub fn project_root(start: &Path) -> PathBuf { } } +/// Whether `dir` sits in a linked git worktree: its project root holds a +/// `.git` file instead of a directory, and so a journal of its own. +pub fn is_linked_worktree(dir: &Path) -> bool { + let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + + project_root(&dir).join(".git").is_file() +} + pub fn from_path(p: impl AsRef<Path>) -> anyhow::Result<String> { let p = p.as_ref(); // `canonicalize` requires the path to EXIST — it returns ENOENT ("No such diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 1499f37..5d91cbd 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -568,7 +568,10 @@ fn new_task_chronicle( } else { Vec::new() }; - let gaps = tj_core::archive::gaps(conn, project_hash, Some(task_id))?; + let gaps = tj_core::archive::for_dir( + tj_core::archive::gaps(conn, project_hash, Some(task_id))?, + &project_dir()?, + ); Ok((modules, suggested, tj_core::archive::headline(&gaps))) }) @@ -1227,7 +1230,10 @@ impl TaskJournalServer { } with_state(|conn, project_hash| { - let gaps = tj_core::archive::gaps(conn, project_hash, None)?; + let gaps = tj_core::archive::for_dir( + tj_core::archive::gaps(conn, project_hash, None)?, + &project_dir()?, + ); Ok(Json(ModuleListResult { modules: tj_core::modules::list(conn, project_hash)?, From 08028f8d637430f0c5b04a5ca8db77faf2305cea Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:35:00 +0400 Subject: [PATCH 17/23] =?UTF-8?q?fix(core):=20module=20suggestions=20?= =?UTF-8?q?=E2=80=94=20titles=20only=20back=20a=20match,=20whole-word=20te?= =?UTF-8?q?rms,=20folder=20paths,=20=D1=91=20=3D=20=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tj-core/src/modules.rs | 174 +++++++++++++++++++++++++++++----- 1 file changed, 150 insertions(+), 24 deletions(-) diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index b32d777..6417c18 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -426,20 +426,48 @@ pub struct Suggestion { pub why: String, } +/// Shorter words ("api", "dev", "log") are too generic to tie a task to a +/// module; a module term may still name them. +const MIN_WORD: usize = 4; + // ponytail: a 5-char prefix stands in for stemming (EN/RU inflections); // swap in a real stemmer if suggestions turn noisy. -fn stems(text: &str) -> std::collections::BTreeSet<String> { - crate::recall::keywords(text) - .into_iter() - .filter(|w| !RU_STOPWORDS.contains(&w.as_str())) - .map(|w| w.chars().take(5).collect()) - .collect() +/// The meaningful words of `text` by stem, each with the first word seen. +fn stems(text: &str) -> std::collections::BTreeMap<String, String> { + let mut out = std::collections::BTreeMap::new(); + for word in crate::recall::keywords(text) { + let is_generic = word.chars().count() < MIN_WORD + || RU_STOPWORDS.contains(&word.as_str()) + || word.chars().all(|c| c.is_ascii_digit()); + if is_generic { + continue; + } + + let stem: String = word.replace('ё', "е").chars().take(5).collect(); + out.entry(stem).or_insert(word); + } + + out } +/// Whether `file` lies in the folder (or is the file) `prefix` names. fn is_under(file: &str, prefix: &str) -> bool { - let prefix = prefix.trim_start_matches("./"); + let file = file.trim_start_matches("./"); + let prefix = prefix.trim_start_matches("./").trim_end_matches('/'); + + !prefix.is_empty() + && file.starts_with(prefix) + && (file.len() == prefix.len() || file[prefix.len()..].starts_with('/')) +} + +/// Whether `phrase` stands in `text` as whole words. +fn has_phrase(text: &str, phrase: &str) -> bool { + text.match_indices(phrase).any(|(i, _)| { + let before = text[..i].chars().next_back(); + let after = text[i + phrase.len()..].chars().next(); - !prefix.is_empty() && file.trim_start_matches("./").starts_with(prefix) + !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) + }) } fn task_titles( @@ -471,7 +499,7 @@ pub fn suggest( files: &[String], ) -> anyhow::Result<Vec<Suggestion>> { let words = stems(text); - let lower = text.to_lowercase(); + let lower = text.to_lowercase().replace('ё', "е"); let mut out = Vec::new(); for m in list(conn, project_hash)? @@ -497,27 +525,39 @@ pub fn suggest( .terms .iter() .map(String::as_str) - .filter(|t| t.chars().count() >= 3 && lower.contains(&t.to_lowercase())) + .filter(|t| { + t.chars().count() >= 3 && has_phrase(&lower, &t.to_lowercase().replace('ё', "е")) + }) .collect(); if !terms.is_empty() { score += 2 * terms.len() as u32; why.push(format!("terms: {}", terms.join(", "))); } - let vocabulary = format!( - "{} {} {} {}", + // The module's own words count one each. Words shared only with its + // past task titles back a match up, but never make one alone. + let own = stems(&format!( + "{} {} {}", m.name, m.description.as_deref().unwrap_or(""), - m.hints.terms.join(" "), - task_titles(conn, project_hash, &m.module_id)?.join(" ") - ); - let shared: Vec<String> = words - .intersection(&stems(&vocabulary)) + m.hints.terms.join(" ") + )); + let titles = stems(&task_titles(conn, project_hash, &m.module_id)?.join(" ")); + let shared_own: Vec<&str> = words + .iter() + .filter(|(stem, _)| own.contains_key(*stem)) + .map(|(_, word)| word.as_str()) + .take(5) + .collect(); + let shared_titles: Vec<&str> = words + .iter() + .filter(|(stem, _)| !own.contains_key(*stem) && titles.contains_key(*stem)) + .map(|(_, word)| word.as_str()) .take(5) - .cloned() .collect(); + score += shared_own.len() as u32 + u32::from(!shared_titles.is_empty()); + let shared: Vec<&str> = shared_own.into_iter().chain(shared_titles).collect(); if !shared.is_empty() { - score += shared.len() as u32; why.push(format!("words: {}", shared.join(", "))); } @@ -1131,17 +1171,103 @@ mod tests { .is_empty()); } + fn described(id: &str, description: &str, terms: &[&str], paths: &[&str]) -> Event { + module_event( + id, + &ModuleFields { + name: Some(id.into()), + description: Some(description.into()), + hints: Some(Hints { + paths: paths.iter().map(|p| p.to_string()).collect(), + terms: terms.iter().map(|t| t.to_string()).collect(), + }), + ..Default::default() + }, + ) + .unwrap() + } + + fn titled(id: &str, title: &str, module: &str) -> Event { + let mut t = open_task(id, &[module]); + t.meta["title"] = serde_json::json!(title); + + t + } + #[test] - fn titles_of_a_modules_tasks_join_its_vocabulary() { - let mut t = open_task("tj-a", &["auth"]); - t.meta["title"] = serde_json::json!("Rotate signing keys"); - let (_d, conn) = journal(&[named("auth", "Auth"), t]); + fn past_task_titles_alone_never_suggest_a_module() { + // A module's past titles share generic words with half the backlog; + // they may back a match up, never make one. + let (_d, conn) = journal(&[ + described("auth", "Login", &[], &[]), + titled("tj-a", "Rotate webhook signing keys", "auth"), + ]); - let s = suggest(&conn, "p", "signing keys expire too early", &[]).unwrap(); + assert!(suggest(&conn, "p", "rotate webhook signing keys", &[]) + .unwrap() + .is_empty()); + } + + #[test] + fn past_task_titles_back_up_a_word_of_the_module() { + let (_d, conn) = journal(&[ + described("auth", "Login and signing", &[], &[]), + titled("tj-a", "Rotate webhook keys", "auth"), + ]); + + let s = suggest(&conn, "p", "webhook signing", &[]).unwrap(); assert_eq!(s.first().map(|s| s.module_id.as_str()), Some("auth")); } + #[test] + fn a_term_matches_whole_words_only() { + let (_d, conn) = journal(&[described("logs", "Shipping", &["log"], &[])]); + + assert!(suggest(&conn, "p", "the catalog page is slow", &[]) + .unwrap() + .is_empty()); + assert_eq!(suggest(&conn, "p", "log rotation", &[]).unwrap().len(), 1); + } + + #[test] + fn a_hint_path_matches_a_folder_not_a_name_prefix() { + let (_d, conn) = journal(&[described("stars", "Ratings", &[], &["src/stars"])]); + + assert!(suggest(&conn, "p", "x", &["src/starship/a.rs".into()]) + .unwrap() + .is_empty()); + assert_eq!( + suggest(&conn, "p", "x", &["src/stars/a.rs".into()]) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn yo_and_ye_are_the_same_letter() { + let (_d, conn) = journal(&[described("stars", "Рейтинг звёзд", &[], &[])]); + + assert_eq!(suggest(&conn, "p", "рейтинг звезд", &[]).unwrap().len(), 1); + } + + #[test] + fn why_names_the_tasks_words_not_their_stems() { + let (_d, conn) = mapped(); + + let s = suggest(&conn, "p", "Пересчитать рейтинга звёзды", &[]).unwrap(); + + assert!(s[0].why.contains("рейтинга"), "{}", s[0].why); + } + + #[test] + fn three_letter_words_are_too_generic_to_suggest() { + let (_d, conn) = journal(&[described("tools", "dev api log ops", &[], &[])]); + + assert!(suggest(&conn, "p", "dev api log", &[]).unwrap().is_empty()); + } + #[test] fn retired_modules_are_not_suggested() { let retired = module_event( From 7eaac681add4e7f66ccc59a48ead648885ed5159 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:35:17 +0400 Subject: [PATCH 18/23] docs: correct the 0.31 upgrade notes; move a doc comment back to its function --- CHANGELOG.md | 15 ++++++++++----- crates/tj-cli/src/main.rs | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0190ccd..0ba65ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,22 +38,27 @@ itself: the journal says what is missing and what to do about it. mid-session. The mod's status line shows the task's modules. - **Mapping and sorting old work.** `/task-journal:map` and the skill's steps: propose modules, confirm with the user, save them, sort past tasks with - `module_backfill_candidates`, confirm, link, write each module's first state. + `module_backfill_candidates` (paged with `limit` / `offset`), confirm, link — + leftovers to a catch-all module — and write each module's first state. - CLI: `module list|show|save|link|candidates`, `create --modules`, `close --module-note module=text`; `task_search` / MCP gain a `module` filter. ### Changed - `migrate-project` re-keys the module tables too. -- Task views (`events list`, `export --format md|html|sqlite`, backfill) skip - `module` lines; `export --format json` keeps the full journal. +- Task views (`events list`, every `export` format, backfill) skip `module` + lines, so hosts that group an export by task id see no phantom tasks. The + JSONL log itself remains the full record. ### Upgrading - Restart open Claude Code and Codex sessions after `cargo install --force`. The first command re-indexes the project once. - A 0.30 binary next to 0.31 skips `module` lines with a warning and keeps all tasks; 0.31 re-indexes once afterwards, so no module is lost. -- Nothing changes until you create a module: existing tools, packs and replies - look as before. +- Existing tools, commands and packs work as before. What is new before you + create a module: the session start shows a `📚 Chronicle:` line inviting you + to map the project (not in a git worktree), `task_create` replies may carry + `chronicle` and `suggested_modules`, and `task-journal state` gains + `archive` and `active.modules`. ## [0.30.0] - 2026-10-04 diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 241d551..e2106da 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -122,9 +122,6 @@ fn dir_writable(dir: &std::path::Path) -> bool { r } -/// Read a project's JSONL event log. Malformed lines are skipped with a -/// warning on stderr, the same policy as `rebuild_state`, so one bad line -/// cannot abort a read-only command. /// Run `f` on the current project's state, with the journal ingested first. fn with_chronicle<T>(f: impl FnOnce(&rusqlite::Connection, &str) -> Result<T>) -> Result<T> { let cwd = std::env::current_dir()?; @@ -290,6 +287,9 @@ fn run_module(action: ModuleCmd) -> Result<()> { Ok(()) } +/// Read a project's JSONL event log. Malformed lines are skipped with a +/// warning on stderr, the same policy as `rebuild_state`, so one bad line +/// cannot abort a read-only command. fn read_events_lenient( path: &std::path::Path, command: &str, From 9745dbde1046617b8853b25a4597598772342249 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 17:58:38 +0400 Subject: [PATCH 19/23] =?UTF-8?q?feat:=20one=20module=20map=20per=20reposi?= =?UTF-8?q?tory=20=E2=80=94=20git=20worktrees=20share=20their=20main=20che?= =?UTF-8?q?ckout's=20chronicle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +- README.md | 2 +- crates/tj-cli/src/main.rs | 101 +++++--- crates/tj-cli/tests/cli.rs | 108 ++++++-- crates/tj-core/src/archive.rs | 101 ++++---- crates/tj-core/src/chronicle.rs | 370 ++++++++++++++++++++++++++++ crates/tj-core/src/lib.rs | 1 + crates/tj-core/src/modules.rs | 109 +++++--- crates/tj-core/src/pack.rs | 9 +- crates/tj-core/src/project_hash.rs | 71 +++++- crates/tj-core/src/session_state.rs | 56 +++-- crates/tj-mcp/src/main.rs | 137 ++++++---- plugin/skills/task-journal/SKILL.md | 3 +- 13 files changed, 869 insertions(+), 207 deletions(-) create mode 100644 crates/tj-core/src/chronicle.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba65ae..6953b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,12 @@ itself: the journal says what is missing and what to do about it. propose modules, confirm with the user, save them, sort past tasks with `module_backfill_candidates` (paged with `limit` / `offset`), confirm, link — leftovers to a catch-all module — and write each module's first state. +- **One map per repository, worktrees included.** A git worktree reads and + writes the module map of its main checkout, while its tasks stay in its own + journal. A module's page, task counts and staleness gather the tasks of the + main checkout and of every worktree, removed ones too. Worktrees register + next to the journals, and their module links carry the main checkout's id, + so the registry can be rebuilt from the journals. - CLI: `module list|show|save|link|candidates`, `create --modules`, `close --module-note module=text`; `task_search` / MCP gain a `module` filter. @@ -56,7 +62,7 @@ itself: the journal says what is missing and what to do about it. tasks; 0.31 re-indexes once afterwards, so no module is lost. - Existing tools, commands and packs work as before. What is new before you create a module: the session start shows a `📚 Chronicle:` line inviting you - to map the project (not in a git worktree), `task_create` replies may carry + to map the project, `task_create` replies may carry `chronicle` and `suggested_modules`, and `task-journal state` gains `archive` and `active.modules`. diff --git a/README.md b/README.md index 6f94f52..101c407 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ that tie it back to the code. - **The mod (Claude Code 2.1.287+).** A plugin of function hooks that runs inside Claude Code and calls the `task-journal` CLI. It keeps the session's active task in the system prompt (a compaction can't drop it), gives each session its own active task, shows the task in the status line, reminds the agent to log only after several prompts without an entry, and — right before a compaction — asks the model what was never logged and records it as `suggested` events. It needs no setup. When it runs, the classic hooks skip what it replaces (the reminder, per-message classification and the transcript catch-ups) and keep the rest (resume packs, push-recall, `/rewind`). Codex and Claude Code older than 2.1.287 keep using the hooks below; there the mod simply doesn't load. - **Self-tagging is the primary path (recommended).** You — the agent in the live session — record reasoning directly via the five MCP tools: open a task with a `goal`, append a typed `decision` / `finding` / `rejection` / `evidence` event at the moment of commitment, and `task_close` with a written `outcome`. This is free (it rides the interactive session), language-agnostic, and higher-fidelity than any after-the-fact classifier. The bundled `task-journal` skill drives this automatically. See [MCP tools](#mcp-tools). - **Auto-capture is an opt-in backstop — OFF by default (v0.14.0).** A fresh `install-hooks` wires only a cheap, read-only SessionStart resume hook: no per-message classifier runs, no `claude -p` is ever spawned, nothing is charged. Self-tagging is the capture mechanism. Opt in with `install-hooks --auto-capture` and Claude Code hooks also run every prompt, tool call, and reply through a two-stage classifier that lands typed events on its own: Stage 1 is a fast in-process heuristic (obvious EN+RU phrasing, zero cost); Stage 2 falls back to an LLM only when the heuristic is uncertain (and only if you pick `--backend agent-sdk` / `api`). Even opted in it is a safety net under your explicit self-tagging, not the main mechanism, and it misses real reasoning — especially non-English prose. -- **Project chronicle (0.31).** The journal is also a map of the system: the project splits into **modules** — parts of the system by meaning, not folders or tickets — and every task belongs to one or more. Each module has a page: how it works now, the decisions, rejections and constraints of all its tasks, and its history, one line per task. A new task can start from that page; closing it adds its line. The journal suggests a module for a new task (from its words and files, no model call) and names what the chronicle is missing — no map yet, tasks without a module, a module whose state lags behind its tasks — at session start and in tool replies, so the agent keeps it up by itself. `/task-journal:map` (or the skill's steps in any client) maps a project and sorts its past tasks, with the user confirming both. +- **Project chronicle (0.31).** The journal is also a map of the system: the project splits into **modules** — parts of the system by meaning, not folders or tickets — and every task belongs to one or more. Each module has a page: how it works now, the decisions, rejections and constraints of all its tasks, and its history, one line per task. A new task can start from that page; closing it adds its line. The journal suggests a module for a new task (from its words and files, no model call) and names what the chronicle is missing — no map yet, tasks without a module, a module whose state lags behind its tasks — at session start and in tool replies, so the agent keeps it up by itself. `/task-journal:map` (or the skill's steps in any client) maps a project and sorts its past tasks, with the user confirming both. A repository has one map: git worktrees share their main checkout's, and a module's history gathers tasks from every worktree, while each task stays in its own worktree's journal. - **Artifact extraction.** Each event scans its text for commit hashes, PR URLs, file paths, issue IDs, and branch names. Aggregated artifacts are how Task Journal links related tasks: when you start a new task touching the same issue or file, the prior task is surfaced automatically. - **Resume packs.** `task_pack` (MCP tool or CLI) renders a task into a compact Markdown briefing — Goal, Outcome, decisions, rejections, evidence, artifacts — that fits in a fresh agent's context window without dumping the raw event log. - **Auto-capture boundaries.** Beyond per-event capture, two extra hooks mark *reasoning boundaries* automatically. On `PreCompact`, Task Journal reads the transcript JSONL tail (entries newer than the active task's last event) and enqueues anything the synchronous hooks missed before the compact — then drops a marker decision so the post-compact agent sees a clear cut. A `/rewind`-prefixed prompt appends a single correction event so pack readers see where the user rolled back. No mass-rejection of prior events — the boundary is a sentinel, not a rewrite. diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index e2106da..d0b9be7 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -122,23 +122,32 @@ fn dir_writable(dir: &std::path::Path) -> bool { r } -/// Run `f` on the current project's state, with the journal ingested first. -fn with_chronicle<T>(f: impl FnOnce(&rusqlite::Connection, &str) -> Result<T>) -> Result<T> { +/// Run `f` on the project's chronicle (the module map of its repository, a +/// worktree sharing the main checkout's) and on the current project's state. +fn with_chronicle<T>( + f: impl FnOnce(&tj_core::chronicle::Chronicle, &rusqlite::Connection, &str) -> Result<T>, +) -> Result<T> { let cwd = std::env::current_dir()?; + let chr = tj_core::chronicle::Chronicle::open(&cwd)?; 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)?; tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; - f(&conn, &project_hash) + f(&chr, &conn, &project_hash) } /// Append `events` to the current project's journal, durably. fn append_to_journal(events: &[tj_core::event::Event]) -> Result<()> { let project_hash = tj_core::project_hash::from_path(&std::env::current_dir()?)?; let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); - let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; + append_to(&events_path, events) +} + +/// Append `events` to the journal at `path`, durably. +fn append_to(path: &std::path::Path, events: &[tj_core::event::Event]) -> Result<()> { + let mut writer = tj_core::storage::JsonlWriter::open(path)?; for event in events { writer.append(event)?; } @@ -158,13 +167,10 @@ fn by_user(mut event: tj_core::event::Event) -> tj_core::event::Event { fn run_module(action: ModuleCmd) -> Result<()> { match action { ModuleCmd::List { json } => { - let (modules, gaps) = with_chronicle(|conn, hash| { + let (modules, gaps) = with_chronicle(|chr, conn, hash| { Ok(( - tj_core::modules::list(conn, hash)?, - tj_core::archive::for_dir( - tj_core::archive::gaps(conn, hash, None)?, - &std::env::current_dir()?, - ), + tj_core::modules::map(chr)?, + tj_core::archive::gaps(chr, conn, hash, None)?, )) })?; if json { @@ -200,7 +206,7 @@ fn run_module(action: ModuleCmd) -> Result<()> { } } ModuleCmd::Show { module_id } => { - let page = with_chronicle(|conn, hash| tj_core::modules::page(conn, hash, &module_id))?; + let page = with_chronicle(|chr, _, _| tj_core::modules::page(chr, &module_id))?; println!("{page}"); } ModuleCmd::Save { @@ -225,7 +231,10 @@ fn run_module(action: ModuleCmd) -> Result<()> { }; let event = tj_core::modules::module_event(&module_id, &fields)?; - let created = with_chronicle(|conn, hash| { + // Modules live in the repository's home journal, also when saved + // from a worktree. + let (created, home_events) = with_chronicle(|chr, _, _| { + let (conn, hash) = (&chr.home, chr.home_hash.as_str()); let exists = tj_core::modules::get(conn, hash, &module_id)?.is_some(); if !exists && fields.name.is_none() { anyhow::bail!("module {module_id} is new: pass --name too"); @@ -239,9 +248,9 @@ fn run_module(action: ModuleCmd) -> Result<()> { } } - Ok(!exists) + Ok((!exists, chr.home_events())) })?; - append_to_journal(&[by_user(event)])?; + append_to(&home_events, &[by_user(event)])?; println!( "{} {module_id}", if created { "created" } else { "updated" } @@ -252,7 +261,7 @@ fn run_module(action: ModuleCmd) -> Result<()> { add, remove, } => { - let resolved = with_chronicle(|conn, hash| { + let event = with_chronicle(|chr, conn, _| { if !tj_core::db::task_exists(conn, &task_id)? { anyhow::bail!("task not found: {task_id}"); } @@ -260,24 +269,24 @@ fn run_module(action: ModuleCmd) -> Result<()> { tj_core::modules::validate_id(id)?; } - tj_core::modules::resolve(conn, hash, &add) + let resolved = tj_core::modules::resolve(&chr.home, &chr.home_hash, &add)?; + for w in &resolved.warnings { + eprintln!("warning: {w}"); + } + if resolved.ids.is_empty() && remove.is_empty() { + anyhow::bail!("nothing to do: pass --add or --remove"); + } + let mut event = tj_core::modules::link_event(&task_id, &resolved.ids, &remove); + chr.stamp(&mut event.meta); + + Ok(event) })?; - for w in &resolved.warnings { - eprintln!("warning: {w}"); - } - if resolved.ids.is_empty() && remove.is_empty() { - anyhow::bail!("nothing to do: pass --add or --remove"); - } - append_to_journal(&[by_user(tj_core::modules::link_event( - &task_id, - &resolved.ids, - &remove, - ))])?; + append_to_journal(&[by_user(event)])?; println!("linked {task_id}"); } ModuleCmd::Candidates { limit, offset } => { - let (total, candidates) = with_chronicle(|conn, hash| { - tj_core::modules::backfill_candidates(conn, hash, limit, offset) + let (total, candidates) = with_chronicle(|chr, conn, hash| { + tj_core::modules::backfill_candidates(chr, conn, hash, limit, offset) })?; let out = serde_json::json!({ "total_unlinked": total, "candidates": candidates }); println!("{}", serde_json::to_string(&out)?); @@ -1673,12 +1682,13 @@ fn real_main() -> Result<()> { meta["goal"] = serde_json::Value::String(g); } if !modules.is_empty() { - let resolved = - with_chronicle(|conn, hash| tj_core::modules::resolve(conn, hash, &modules))?; + let chr = tj_core::chronicle::Chronicle::open(&cwd)?; + let resolved = tj_core::modules::resolve(&chr.home, &chr.home_hash, &modules)?; for w in &resolved.warnings { eprintln!("warning: {w}"); } meta["modules"] = serde_json::json!(resolved.ids); + chr.stamp(&mut meta); } event.meta = meta; @@ -2009,7 +2019,7 @@ fn real_main() -> Result<()> { let session = session.or_else(tj_core::session_id::session_id_from_env); // No journal yet: an empty state, and no state DB created for it. - let mut state = if events_path.exists() { + let state = if events_path.exists() { let conn = tj_core::db::open(&state_path)?; tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?; tj_core::session_state::session_state( @@ -2017,6 +2027,7 @@ fn real_main() -> Result<()> { &project_hash, session.as_deref(), prefer.as_deref(), + &tj_core::chronicle::Chronicle::open(&cwd)?, )? } else { tj_core::session_state::SessionState { @@ -2028,7 +2039,6 @@ fn real_main() -> Result<()> { } }; - state.archive = tj_core::archive::for_dir(state.archive, &cwd); println!("{}", serde_json::to_string(&state)?); } Commands::ArtifactAdd { @@ -2100,9 +2110,18 @@ fn real_main() -> Result<()> { } let open_kids = tj_core::db::count_open_children(&conn, &task_id)?; // A note to an unknown module fails the close before it is written. + let chr = if notes.is_empty() { + None + } else { + Some(tj_core::chronicle::Chronicle::open(&cwd)?) + }; for (module, _) in notes.iter_mut() { - let resolved = - tj_core::modules::resolve(&conn, &project_hash, std::slice::from_ref(module))?; + let chr = chr.as_ref().expect("opened when notes are given"); + let resolved = tj_core::modules::resolve( + &chr.home, + &chr.home_hash, + std::slice::from_ref(module), + )?; for w in &resolved.warnings { eprintln!("warning: {w}"); } @@ -2132,6 +2151,14 @@ fn real_main() -> Result<()> { } if !notes.is_empty() { meta.insert("module_notes".into(), tj_core::modules::notes_meta(¬es)); + if let Some(chr) = &chr { + if chr.member_hash.is_some() { + meta.insert( + tj_core::chronicle::HOME_KEY.into(), + chr.home_hash.clone().into(), + ); + } + } } // Layer-2 close harvest: stamp deterministic git/gh refs (commit, // branch, PR) so the closed pack reads as a clickable ledger of @@ -2981,9 +3008,9 @@ runs in the background and won't block you; it only fills gaps and never closes // The chronicle's most important gap, for every client that // reads this hook (Claude Code with or without the mod, Codex). // Advice only: it never fails the session start. - if let Some(line) = tj_core::archive::gaps(&conn, &project_hash, None) + if let Some(line) = tj_core::chronicle::Chronicle::open(&cwd) + .and_then(|chr| tj_core::archive::gaps(&chr, &conn, &project_hash, None)) .ok() - .map(|gaps| tj_core::archive::for_dir(gaps, &cwd)) .and_then(|gaps| tj_core::archive::headline(&gaps)) { bundle.push_str(&line); diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 6a8deb3..abdd636 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -8739,32 +8739,98 @@ fn state_carries_archive_gaps() { .stdout(contains(r#""kind":"no_map""#)); } +/// A git repository with one linked worktree: their own journals, one map. +fn repo_with_worktree(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + let main = root.join("repo"); + std::fs::create_dir_all(&main).unwrap(); + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&main) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}"); + }; + git(&["init", "-q"]); + git(&[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "--allow-empty", + "-m", + "x", + ]); + let worktree = root.join("wt"); + git(&["worktree", "add", "-q", worktree.to_str().unwrap()]); + + (main, worktree) +} + #[test] -fn a_worktree_session_is_not_asked_to_map_the_project() { - let (xdg, proj) = ( - assert_fs::TempDir::new().unwrap(), - assert_fs::TempDir::new().unwrap(), - ); - std::fs::write( - proj.path().join(".git"), - "gitdir: /elsewhere/.git/worktrees/x\n", - ) - .unwrap(); - let tj = chronicle_cli(&xdg, &proj); - tj().args(["create", "Work in a worktree"]) +fn a_worktree_shares_the_main_checkouts_module_map() { + let xdg = assert_fs::TempDir::new().unwrap(); + let root = assert_fs::TempDir::new().unwrap(); + let (main, worktree) = repo_with_worktree(root.path()); + let tj = |dir: &std::path::Path| { + let mut c = Command::cargo_bin("task-journal").unwrap(); + c.env("XDG_DATA_HOME", xdg.path()).current_dir(dir); + c + }; + + tj(&main) + .args(["module", "save", "stars", "--name", "Stars"]) .assert() .success(); - let mut hook = tj(); - hook.args(["ingest-hook", "--kind", "SessionStart", "--text", ""]); - let out: serde_json::Value = serde_json::from_str(&stdout_of(hook)).unwrap(); - let ctx = out["hookSpecificOutput"]["additionalContext"] - .as_str() - .unwrap(); - - assert!(!ctx.contains("no module map"), "{ctx}"); - tj().args(["state"]) + // In the worktree: the same map, a task of its own linked to it. + tj(&worktree) + .args(["module", "list"]) + .assert() + .success() + .stdout(contains("stars")); + let mut create = tj(&worktree); + create.args(["create", "Rank in the worktree", "--modules", "stars"]); + let id = stdout_of(create); + tj(&worktree) + .args([ + "close", + &id, + "--outcome", + "Ranked", + "--module-note", + "stars=Ranked from the worktree", + ]) + .assert() + .success(); + tj(&worktree) + .args(["module", "save", "auth", "--name", "Auth"]) + .assert() + .success(); + tj(&worktree) + .args(["state"]) .assert() .success() .stdout(contains("no_map").not()); + + // In the main checkout: the worktree's task is in the module's history, + // and the module saved from the worktree is on the map. + tj(&main) + .args(["module", "show", "stars"]) + .assert() + .success() + .stdout(contains(id.as_str()).and(contains("Ranked from the worktree"))); + tj(&main) + .args(["module", "list"]) + .assert() + .success() + .stdout(contains("auth").and(contains("1 task(s)"))); + // The task itself stays in the worktree's journal. + tj(&main) + .args(["list"]) + .assert() + .success() + .stdout(contains(id.as_str()).not()); } diff --git a/crates/tj-core/src/archive.rs b/crates/tj-core/src/archive.rs index 538d426..383b9ab 100644 --- a/crates/tj-core/src/archive.rs +++ b/crates/tj-core/src/archive.rs @@ -49,20 +49,6 @@ impl Gap { } } -/// `gaps` as seen from `dir`. A linked git worktree keeps a journal of its -/// own while the map lives in the main checkout, so it is not asked to map. -// ponytail: hides NoMap in worktrees; reading the main checkout's chronicle -// from a worktree is the real fix. -pub fn for_dir(gaps: Vec<Gap>, dir: &std::path::Path) -> Vec<Gap> { - if !crate::project_hash::is_linked_worktree(dir) { - return gaps; - } - - gaps.into_iter() - .filter(|g| !matches!(g, Gap::NoMap { .. })) - .collect() -} - /// The line a session start or a tool reply shows: the most important gap. pub fn headline(gaps: &[Gap]) -> Option<String> { let first = gaps.first()?; @@ -74,9 +60,12 @@ pub fn headline(gaps: &[Gap]) -> Option<String> { Some(format!("📚 Chronicle: {}{more}", first.action())) } -/// What the chronicle of `project_hash` is missing, most important first. +/// What the chronicle is missing, most important first, as seen from the +/// journal `conn` (`project_hash`): the map comes from the chronicle's home, +/// tasks from this journal, and a module's lag from every journal. /// `active_task` is the session's task, checked for a module of its own. pub fn gaps( + chr: &crate::chronicle::Chronicle, conn: &Connection, project_hash: &str, active_task: Option<&str>, @@ -89,7 +78,8 @@ pub fn gaps( if tasks == 0 { return Ok(Vec::new()); } - if count("SELECT COUNT(*) FROM modules WHERE project_hash = ?1")? == 0 { + let modules: Vec<crate::modules::Module> = crate::modules::list(&chr.home, &chr.home_hash)?; + if modules.is_empty() { return Ok(vec![Gap::NoMap { tasks }]); } @@ -105,22 +95,40 @@ pub fn gaps( } } - let mut stmt = conn.prepare( - "SELECT m.module_id, COUNT(t.task_id) FROM modules m - JOIN task_modules tm ON tm.project_hash = m.project_hash AND tm.module_id = m.module_id - JOIN tasks t ON t.task_id = tm.task_id AND t.status = 'closed' - WHERE m.project_hash = ?1 AND m.status = 'active' - AND (m.state_at IS NULL OR COALESCE(t.closed_at, t.last_event_at) > m.state_at) - GROUP BY m.module_id - ORDER BY COUNT(t.task_id) DESC, m.module_id", - )?; - for row in stmt.query_map([project_hash], |r| Ok((r.get(0)?, r.get(1)?)))? { - let (module_id, closed_since) = row?; - out.push(Gap::StaleModule { - module_id, - closed_since, - }); + let mut stale = Vec::new(); + for m in modules.iter().filter(|m| m.status == "active") { + let mut closed_since = 0; + for journal in chr.journals() { + closed_since += journal.query_row( + "SELECT COUNT(*) FROM task_modules tm + JOIN tasks t ON t.task_id = tm.task_id AND t.status = 'closed' + WHERE tm.module_id = ?1 + AND (?2 IS NULL OR COALESCE(t.closed_at, t.last_event_at) > ?2)", + rusqlite::params![m.module_id, m.state_at], + |r| r.get::<_, i64>(0), + )?; + } + if closed_since > 0 { + stale.push(Gap::StaleModule { + module_id: m.module_id.clone(), + closed_since, + }); + } } + stale.sort_by(|a, b| match (a, b) { + ( + Gap::StaleModule { + module_id: x, + closed_since: n, + }, + Gap::StaleModule { + module_id: y, + closed_since: k, + }, + ) => k.cmp(n).then_with(|| x.cmp(y)), + _ => std::cmp::Ordering::Equal, + }); + out.extend(stale); let unlinked = count( "SELECT COUNT(*) FROM tasks t WHERE t.project_hash = ?1 @@ -136,7 +144,7 @@ pub fn gaps( #[cfg(test)] mod tests { use super::*; - use crate::modules::tests_support::{close_task, journal, open_task}; + use crate::modules::tests_support::{chronicle_of, close_task, journal, open_task}; use crate::modules::{module_event, ModuleFields}; fn stars(state: Option<&str>) -> crate::event::Event { @@ -155,7 +163,9 @@ mod tests { fn an_empty_journal_has_no_gaps() { let (_d, conn) = journal(&[]); - assert!(gaps(&conn, "p", None).unwrap().is_empty()); + assert!(gaps(&chronicle_of(&_d), &conn, "p", None) + .unwrap() + .is_empty()); } #[test] @@ -163,7 +173,7 @@ mod tests { let (_d, conn) = journal(&[open_task("tj-a", &[]), open_task("tj-b", &[])]); assert_eq!( - gaps(&conn, "p", None).unwrap(), + gaps(&chronicle_of(&_d), &conn, "p", None).unwrap(), vec![Gap::NoMap { tasks: 2 }] ); } @@ -180,7 +190,7 @@ mod tests { close, open_task("tj-b", &[]), ]); - let g = gaps(&conn, "p", Some("tj-b")).unwrap(); + let g = gaps(&chronicle_of(&_d), &conn, "p", Some("tj-b")).unwrap(); assert_eq!( g, @@ -208,7 +218,9 @@ mod tests { crate::modules::link_event("tj-a", &["stars".into()], &[]), ]); - assert!(gaps(&conn, "p", None).unwrap().is_empty()); + assert!(gaps(&chronicle_of(&_d), &conn, "p", None) + .unwrap() + .is_empty()); } #[test] @@ -220,7 +232,7 @@ mod tests { ]); assert_eq!( - gaps(&conn, "p", None).unwrap(), + gaps(&chronicle_of(&_d), &conn, "p", None).unwrap(), vec![Gap::StaleModule { module_id: "stars".into(), closed_since: 1 @@ -242,21 +254,6 @@ mod tests { assert!(headline(&[]).is_none()); } - #[test] - fn a_linked_worktree_is_not_asked_to_map_the_project() { - // A worktree (its root holds a `.git` file) has its own journal; the - // map lives in the main checkout, so asking to map again is wrong. - let main = tempfile::TempDir::new().unwrap(); - std::fs::create_dir_all(main.path().join(".git")).unwrap(); - std::fs::write(main.path().join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); - let worktree = tempfile::TempDir::new().unwrap(); - std::fs::write(worktree.path().join(".git"), "gitdir: /elsewhere\n").unwrap(); - let gaps = vec![Gap::NoMap { tasks: 2 }]; - - assert_eq!(for_dir(gaps.clone(), main.path()), gaps); - assert!(for_dir(gaps, worktree.path()).is_empty()); - } - #[test] fn gaps_serialize_with_a_kind_tag() { let json = serde_json::to_string(&Gap::UnlinkedTasks { count: 2 }).unwrap(); diff --git a/crates/tj-core/src/chronicle.rs b/crates/tj-core/src/chronicle.rs new file mode 100644 index 0000000..0a2cde0 --- /dev/null +++ b/crates/tj-core/src/chronicle.rs @@ -0,0 +1,370 @@ +//! Where a project's chronicle lives. The module map sits in one journal, +//! the home: the main checkout of a repository. Tasks linked to modules sit +//! in the home's journal or in the journals of its git worktrees, the +//! members. A member records itself in a registry next to the journals, and +//! the chronicle events it writes carry `meta.chronicle_home`, so a lost +//! registry is rebuilt from the journals themselves. + +use std::path::{Path, PathBuf}; + +use rusqlite::Connection; + +/// The `meta` key a member's chronicle events carry: the home's hash. +pub const HOME_KEY: &str = "chronicle_home"; + +pub struct Chronicle { + /// The home journal's project hash. + pub home_hash: String, + /// The home's state: the modules, and the home's own tasks. + pub home: Connection, + /// The states of the other journals that may hold linked tasks. + pub members: Vec<Connection>, + /// The current project's hash when it is a member (a worktree). + pub member_hash: Option<String>, + data: PathBuf, +} + +impl Chronicle { + /// The chronicle of the project at `dir`, every journal ingested. + pub fn open(dir: &Path) -> anyhow::Result<Self> { + Self::open_in(&crate::paths::data_dir()?, dir) + } + + /// [`Chronicle::open`] with the data dir given. + pub fn open_in(data: &Path, dir: &Path) -> anyhow::Result<Self> { + let current = crate::project_hash::from_path(dir)?; + let home_hash = match crate::project_hash::chronicle_home(dir) { + Some(root) => crate::project_hash::from_path(root)?, + None => current.clone(), + }; + let member_hash = (home_hash != current).then_some(current); + if let Some(member) = &member_hash { + register(data, &home_hash, member)?; + } + + let home = open_journal(data, &home_hash)?; + let mut members = Vec::new(); + for hash in member_hashes(data, &home_hash)? { + if hash != home_hash && data.join("events").join(format!("{hash}.jsonl")).exists() { + members.push(open_journal(data, &hash)?); + } + } + + Ok(Self { + home_hash, + home, + members, + member_hash, + data: data.to_path_buf(), + }) + } + + /// A chronicle of one journal that holds both the modules and the tasks. + pub fn single(conn: Connection, hash: &str) -> Self { + Self { + home_hash: hash.to_string(), + home: conn, + members: Vec::new(), + member_hash: None, + data: PathBuf::new(), + } + } + + /// Every state that holds tasks, the home first. + pub fn journals(&self) -> impl Iterator<Item = &Connection> { + std::iter::once(&self.home).chain(self.members.iter()) + } + + /// The home's journal file: where module events go. + pub fn home_events(&self) -> PathBuf { + self.data + .join("events") + .join(format!("{}.jsonl", self.home_hash)) + } + + /// Mark an event a member writes about modules with its home, so the + /// home finds the member again even without the registry. + pub fn stamp(&self, meta: &mut serde_json::Value) { + if self.member_hash.is_some() { + meta[HOME_KEY] = serde_json::Value::String(self.home_hash.clone()); + } + } + + /// Re-read the home's journal after writing to it. + pub fn refresh_home(&self) -> anyhow::Result<()> { + crate::db::ingest_new_events(&self.home, self.home_events(), &self.home_hash)?; + + Ok(()) + } +} + +fn open_journal(data: &Path, hash: &str) -> anyhow::Result<Connection> { + let conn = crate::db::open(data.join("state").join(format!("{hash}.sqlite")))?; + crate::db::ingest_new_events( + &conn, + data.join("events").join(format!("{hash}.jsonl")), + hash, + )?; + + Ok(conn) +} + +fn registry(data: &Path, home: &str) -> PathBuf { + data.join("chronicle").join(format!("{home}.members")) +} + +/// The journals known to link tasks to `home`'s modules. Without a registry +/// file the journals are scanned for members' marks once, and the result kept. +fn member_hashes(data: &Path, home: &str) -> anyhow::Result<Vec<String>> { + let path = registry(data, home); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let found = scan(data, home)?; + std::fs::create_dir_all(path.parent().expect("registry has a parent"))?; + std::fs::write( + &path, + found.iter().map(|h| format!("{h}\n")).collect::<String>(), + )?; + return Ok(found); + } + Err(e) => return Err(e.into()), + }; + + let mut out: Vec<String> = Vec::new(); + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + if !out.iter().any(|h| h == line) { + out.push(line.to_string()); + } + } + + Ok(out) +} + +fn register(data: &Path, home: &str, member: &str) -> anyhow::Result<()> { + if member_hashes(data, home)?.iter().any(|h| h == member) { + return Ok(()); + } + + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(registry(data, home))?; + f.write_all(format!("{member}\n").as_bytes())?; + + Ok(()) +} + +/// Journals whose lines carry `home` as their chronicle home. +fn scan(data: &Path, home: &str) -> anyhow::Result<Vec<String>> { + let needle = format!("\"{HOME_KEY}\":\"{home}\""); + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(data.join("events")) else { + return Ok(out); + }; + + for entry in entries { + let path = entry?.path(); + let is_journal = path.extension().is_some_and(|x| x == "jsonl"); + if is_journal && std::fs::read_to_string(&path).is_ok_and(|t| t.contains(&needle)) { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + out.push(stem.to_string()); + } + } + } + out.sort(); + + Ok(out) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::*; + use crate::event::{Author, Event, EventType, Source}; + use crate::modules::tests_support::{close_task, open_task}; + use crate::modules::{module_event, notes_meta, ModuleFields}; + + /// A git repository with one linked worktree, and a data dir for journals. + struct Repo { + _dir: tempfile::TempDir, + data: PathBuf, + main: PathBuf, + worktree: PathBuf, + } + + fn repo() -> Repo { + let dir = tempfile::TempDir::new().unwrap(); + let main = dir.path().join("repo"); + std::fs::create_dir_all(&main).unwrap(); + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args(args) + .current_dir(&main) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}"); + }; + git(&["init", "-q"]); + git(&[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "--allow-empty", + "-m", + "x", + ]); + let worktree = dir.path().join("wt"); + git(&["worktree", "add", "-q", worktree.to_str().unwrap()]); + + Repo { + data: dir.path().join("data"), + _dir: dir, + main, + worktree, + } + } + + fn write(data: &Path, project: &Path, events: &[Event]) -> String { + let hash = crate::project_hash::from_path(project).unwrap(); + let mut w = + crate::storage::JsonlWriter::open(data.join("events").join(format!("{hash}.jsonl"))) + .unwrap(); + for e in events { + w.append(e).unwrap(); + } + + hash + } + + fn stars() -> Event { + module_event( + "stars", + &ModuleFields { + name: Some("Stars".into()), + ..Default::default() + }, + ) + .unwrap() + } + + /// The home holds the module and a task; the worktree holds another task + /// of the same module, with a decision and a history note. + fn populated() -> Repo { + let r = repo(); + let home = write( + &r.data, + &r.main, + &[ + stars(), + open_task("tj-home", &["stars"]), + close_task("tj-home", serde_json::json!({})), + ], + ); + + let mut open = open_task("tj-wt", &["stars"]); + open.meta[HOME_KEY] = serde_json::json!(home); + let decision = Event::new( + "tj-wt", + EventType::Decision, + Author::Agent, + Source::Chat, + "Cache the feed".into(), + ); + let mut close = close_task( + "tj-wt", + serde_json::json!({"module_notes": notes_meta(&[("stars".into(), "Feed cached in the worktree".into())])}), + ); + close.meta[HOME_KEY] = serde_json::json!(home); + write(&r.data, &r.worktree, &[open, decision, close]); + + r + } + + #[test] + fn a_worktree_reads_the_home_map_and_the_whole_history() { + let r = populated(); + + let chr = Chronicle::open_in(&r.data, &r.worktree).unwrap(); + + assert_eq!( + chr.home_hash, + crate::project_hash::from_path(&r.main).unwrap() + ); + assert!(chr.member_hash.is_some()); + let page = crate::modules::page(&chr, "stars").unwrap(); + for needle in [ + "tj-home", + "tj-wt", + "Feed cached in the worktree", + "Cache the feed", + ] { + assert!(page.contains(needle), "missing {needle}:\n{page}"); + } + let map = crate::modules::map(&chr).unwrap(); + assert_eq!(map[0].task_count, 2); + } + + #[test] + fn the_home_sees_its_worktrees_tasks_once_they_registered() { + let r = populated(); + Chronicle::open_in(&r.data, &r.worktree).unwrap(); + + let chr = Chronicle::open_in(&r.data, &r.main).unwrap(); + + assert!(chr.member_hash.is_none()); + assert!(crate::modules::page(&chr, "stars") + .unwrap() + .contains("tj-wt")); + } + + #[test] + fn a_lost_registry_is_rebuilt_from_the_journals() { + let r = populated(); + // The worktree never opened its chronicle, so it never registered: + // only the marks in its journal tell the home about it. + assert!(!r.data.join("chronicle").exists()); + + let chr = Chronicle::open_in(&r.data, &r.main).unwrap(); + + assert!(crate::modules::page(&chr, "stars") + .unwrap() + .contains("tj-wt")); + assert!( + r.data.join("chronicle").exists(), + "the rebuilt registry is kept" + ); + } + + #[test] + fn a_project_without_worktrees_is_its_own_home() { + let r = repo(); + let home = write(&r.data, &r.main, &[stars()]); + + let chr = Chronicle::open_in(&r.data, &r.main).unwrap(); + + assert_eq!(chr.home_hash, home); + assert!(chr.member_hash.is_none()); + assert!(chr.members.is_empty()); + } + + #[test] + fn only_a_member_stamps_its_events_with_the_home() { + let r = populated(); + let mut meta = serde_json::json!({}); + + Chronicle::open_in(&r.data, &r.main) + .unwrap() + .stamp(&mut meta); + assert!(meta.get(HOME_KEY).is_none()); + + let chr = Chronicle::open_in(&r.data, &r.worktree).unwrap(); + chr.stamp(&mut meta); + assert_eq!(meta[HOME_KEY], chr.home_hash); + } +} diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index a72ce2c..4b4155f 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -59,6 +59,7 @@ mod task_id_tests { pub mod archive; pub mod artifacts; +pub mod chronicle; pub mod classifier; pub mod completeness; pub mod consolidate; diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index 6417c18..4a15009 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -345,6 +345,38 @@ pub fn list(conn: &Connection, project_hash: &str) -> anyhow::Result<Vec<Module> Ok(rows) } +/// The module map of a chronicle: [`list`] of the home, with task counts and +/// last activity summed over every journal of the repository. +pub fn map(chr: &crate::chronicle::Chronicle) -> anyhow::Result<Vec<Module>> { + let mut modules = list(&chr.home, &chr.home_hash)?; + + for conn in &chr.members { + let mut stmt = conn.prepare( + "SELECT tm.module_id, COUNT(*), MAX(t.last_event_at) + FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id + GROUP BY tm.module_id", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, Option<String>>(2)?, + )) + })?; + for row in rows { + let (id, count, last) = row?; + if let Some(m) = modules.iter_mut().find(|m| m.module_id == id) { + m.task_count += count; + if last > m.last_activity { + m.last_activity = last; + } + } + } + } + + Ok(modules) +} + /// `(module_id, name)` of every module `task_id` belongs to. pub fn modules_of_task(conn: &Connection, task_id: &str) -> anyhow::Result<Vec<(String, String)>> { let mut stmt = conn.prepare( @@ -596,8 +628,10 @@ pub struct Candidate { const UNLINKED: &str = "FROM tasks t WHERE t.project_hash = ?1 AND NOT EXISTS (SELECT 1 FROM task_modules tm WHERE tm.task_id = t.task_id)"; -/// A page of tasks without a module, newest first, and how many there are in all. +/// A page of the tasks in `conn`'s journal without a module, newest first, +/// and how many there are in all; suggestions come from the chronicle's map. pub fn backfill_candidates( + chr: &crate::chronicle::Chronicle, conn: &Connection, project_hash: &str, limit: usize, @@ -623,7 +657,7 @@ pub fn backfill_candidates( let meta = crate::db::task_metadata(conn, &task_id)?.unwrap_or_default(); let files = crate::db::task_artifacts(conn, &task_id)?.files; let text = format!("{title} {}", meta.goal.as_deref().unwrap_or("")); - let suggestions = suggest(conn, project_hash, &text, &files)?; + let suggestions = suggest(&chr.home, &chr.home_hash, &text, &files)?; out.push(Candidate { task_id, @@ -658,6 +692,8 @@ const FULL_HISTORY: usize = 30; const SHORT_OUTCOME: usize = 80; struct HistoryRow { + /// Which of the chronicle's journals holds the task. + journal: usize, task_id: String, title: String, status: String, @@ -666,24 +702,21 @@ struct HistoryRow { note: Option<String>, } -fn history( - conn: &Connection, - project_hash: &str, - ids: &[String], -) -> anyhow::Result<Vec<HistoryRow>> { +fn history(conn: &Connection, journal: usize, ids: &[String]) -> anyhow::Result<Vec<HistoryRow>> { let mut stmt = conn.prepare( "SELECT t.task_id, t.title, t.status, COALESCE(t.closed_at, t.last_event_at), t.outcome, (SELECT group_concat(n.text, ' / ') FROM module_notes n WHERE n.task_id = t.task_id AND n.module_id = tm.module_id) FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id - WHERE tm.project_hash = ?1 AND tm.module_id = ?2", + WHERE tm.module_id = ?1", )?; let mut rows = Vec::new(); let mut seen = std::collections::HashSet::new(); for id in ids { - let part = stmt.query_map(rusqlite::params![project_hash, id], |r| { + let part = stmt.query_map([id], |r| { Ok(HistoryRow { + journal, task_id: r.get(0)?, title: r.get(1)?, status: r.get(2)?, @@ -699,7 +732,6 @@ fn history( } } } - rows.sort_by(|a, b| b.at.cmp(&a.at)); Ok(rows) } @@ -761,7 +793,8 @@ fn push_section(out: &mut String, title: &str, items: &[String], limit: usize) { /// A module's page in Markdown: what it is, how it works now, what is /// decided, rejected and constrained across its tasks, and its history. -pub fn page(conn: &Connection, project_hash: &str, module_id: &str) -> anyhow::Result<String> { +pub fn page(chr: &crate::chronicle::Chronicle, module_id: &str) -> anyhow::Result<String> { + let (conn, project_hash) = (&chr.home, chr.home_hash.as_str()); let Some(m) = get(conn, project_hash, module_id)? else { anyhow::bail!("module {module_id:?} does not exist — module_list shows the map"); }; @@ -775,7 +808,15 @@ pub fn page(conn: &Connection, project_hash: &str, module_id: &str) -> anyhow::R })? { ids.push(id?); } - let rows = history(conn, project_hash, &ids)?; + // The module's tasks from every journal of the repository. + let journals: Vec<&Connection> = chr.journals().collect(); + let mut rows = Vec::new(); + for (i, journal) in journals.iter().enumerate() { + rows.extend(history(journal, i, &ids)?); + } + let mut seen = std::collections::HashSet::new(); + rows.retain(|r| seen.insert(r.task_id.clone())); + rows.sort_by(|a, b| b.at.cmp(&a.at)); let (open, closed): (Vec<&HistoryRow>, Vec<&HistoryRow>) = rows.iter().partition(|r| r.status == "open"); @@ -824,6 +865,7 @@ pub fn page(conn: &Connection, project_hash: &str, module_id: &str) -> anyhow::R format!("- {}{marker} ({task})", one_line(text, ENTRY_CHARS)) }; for r in &rows { + let conn = journals[r.journal]; for d in crate::pack::active_decisions(conn, &r.task_id)? { if !crate::pack::is_noise(&d.text) { decisions.push(entry(&d.text, d.marker(), &r.task_id)); @@ -918,6 +960,13 @@ pub(crate) mod tests_support { (d, conn) } + /// A chronicle of the one journal [`journal`] built in `d`. + pub(crate) fn chronicle_of(d: &TempDir) -> crate::chronicle::Chronicle { + let conn = crate::db::open(d.path().join("s.sqlite")).unwrap(); + + crate::chronicle::Chronicle::single(conn, "p") + } + pub(crate) fn open_task(id: &str, modules: &[&str]) -> Event { let mut e = Event::new(id, EventType::Open, Author::Agent, Source::Chat, id.into()); e.meta = serde_json::json!({"title": id, "modules": modules}); @@ -944,7 +993,7 @@ mod tests { use rusqlite::Connection; use tempfile::TempDir; - use super::tests_support::{close_task, journal, open_task}; + use super::tests_support::{chronicle_of, close_task, journal, open_task}; use super::*; use crate::event::{Author, Event, EventType, Source}; @@ -1308,7 +1357,7 @@ mod tests { old.meta["goal"] = serde_json::json!("Speed up the star feed"); let (_d, conn) = journal(&[stars, old, open_task("tj-linked", &["stars"])]); - let (total, page) = backfill_candidates(&conn, "p", 10, 0).unwrap(); + let (total, page) = backfill_candidates(&chronicle_of(&_d), &conn, "p", 10, 0).unwrap(); assert_eq!(total, 1); assert_eq!(page.len(), 1); @@ -1325,8 +1374,8 @@ mod tests { open_task("tj-3", &[]), ]); - let (total, first) = backfill_candidates(&conn, "p", 2, 0).unwrap(); - let (_, rest) = backfill_candidates(&conn, "p", 2, 2).unwrap(); + let (total, first) = backfill_candidates(&chronicle_of(&_d), &conn, "p", 2, 0).unwrap(); + let (_, rest) = backfill_candidates(&chronicle_of(&_d), &conn, "p", 2, 2).unwrap(); assert_eq!((total, first.len(), rest.len()), (3, 2, 1)); assert!(first.iter().all(|c| c.task_id != rest[0].task_id)); @@ -1356,7 +1405,7 @@ mod tests { }), ); - let (_d, conn) = journal(&[ + let (_d, _conn) = journal(&[ stars, open_task("tj-a", &["stars"]), said("tj-a", EventType::Decision, "Use Wilson score"), @@ -1365,7 +1414,7 @@ mod tests { close, open_task("tj-b", &["stars"]), ]); - let p = page(&conn, "p", "stars").unwrap(); + let p = page(&chronicle_of(&_d), "stars").unwrap(); for needle in [ "# Stars (stars)", @@ -1395,25 +1444,25 @@ mod tests { let mut fix = said("tj-a", EventType::Correction, "No: Wilson score"); fix.corrects = Some(wrong.event_id.clone()); - let (_d, conn) = journal(&[ + let (_d, _conn) = journal(&[ named("stars", "Stars"), open_task("tj-a", &["stars"]), wrong, fix, ]); - let p = page(&conn, "p", "stars").unwrap(); + let p = page(&chronicle_of(&_d), "stars").unwrap(); assert!(!p.contains("Use plain average"), "{p}"); } #[test] fn page_names_the_gaps_of_a_module() { - let (_d, conn) = journal(&[ + let (_d, _conn) = journal(&[ named("stars", "Stars"), open_task("tj-a", &["stars"]), close_task("tj-a", serde_json::json!({})), ]); - let p = page(&conn, "p", "stars").unwrap(); + let p = page(&chronicle_of(&_d), "stars").unwrap(); assert!(p.contains("## Gaps"), "{p}"); assert!(p.contains("no state"), "{p}"); @@ -1432,13 +1481,13 @@ mod tests { }, ) .unwrap(); - let (_d, conn) = journal(&[ + let (_d, _conn) = journal(&[ named("stars", "Stars"), named("old", "Old"), open_task("tj-old", &["old"]), old, ]); - let p = page(&conn, "p", "stars").unwrap(); + let p = page(&chronicle_of(&_d), "stars").unwrap(); assert!(p.contains("tj-old"), "{p}"); } @@ -1455,9 +1504,9 @@ mod tests { serde_json::json!({"outcome": "o".repeat(150)}), )); } - let (_d, conn) = journal(&events); + let (_d, _conn) = journal(&events); - let p = page(&conn, "p", "big").unwrap(); + let p = page(&chronicle_of(&_d), "big").unwrap(); assert!(p.len() <= PAGE_BUDGET, "{}", p.len()); assert!(p.contains("## History"), "history cut away"); @@ -1493,9 +1542,9 @@ mod tests { )); } } - let (_d, conn) = journal(&events); + let (_d, _conn) = journal(&events); - let p = page(&conn, "p", "real").unwrap(); + let p = page(&chronicle_of(&_d), "real").unwrap(); assert!(p.len() <= PAGE_BUDGET, "{}", p.len()); for section in [ @@ -1517,9 +1566,9 @@ mod tests { #[test] fn unknown_module_page_is_an_error() { - let (_d, conn) = mapped(); + let (_d, _conn) = mapped(); - let err = page(&conn, "p", "nope").unwrap_err().to_string(); + let err = page(&chronicle_of(&_d), "nope").unwrap_err().to_string(); assert!(err.contains("module_list"), "{err}"); } diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 2a66288..b694a58 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -433,7 +433,14 @@ pub fn assemble(conn: &Connection, task_id: &str, mode: PackMode) -> anyhow::Res if !modules.is_empty() { let names: Vec<String> = modules .iter() - .map(|(id, name)| format!("{name} ({id})")) + // In a worktree the names live in the home's state: show the id. + .map(|(id, name)| { + if name == id { + id.clone() + } else { + format!("{name} ({id})") + } + }) .collect(); text.push_str(&format!("**Modules**: {}\n", names.join(", "))); } diff --git a/crates/tj-core/src/project_hash.rs b/crates/tj-core/src/project_hash.rs index f57e5b9..71de441 100644 --- a/crates/tj-core/src/project_hash.rs +++ b/crates/tj-core/src/project_hash.rs @@ -34,12 +34,23 @@ pub fn project_root(start: &Path) -> PathBuf { } } -/// Whether `dir` sits in a linked git worktree: its project root holds a -/// `.git` file instead of a directory, and so a journal of its own. -pub fn is_linked_worktree(dir: &Path) -> bool { +/// The main checkout of the repository a linked git worktree belongs to, +/// where the project chronicle (the module map) lives. `None` when `dir` is +/// not in a linked worktree: the project is its own home. A worktree's root +/// holds a `.git` file naming its gitdir, whose `commondir` leads to the +/// main repository's `.git`; a submodule's gitdir has no `commondir`. +pub fn chronicle_home(dir: &Path) -> Option<PathBuf> { let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); - - project_root(&dir).join(".git").is_file() + let root = project_root(&dir); + let pointer = std::fs::read_to_string(root.join(".git")).ok()?; + let gitdir = root.join(pointer.trim().strip_prefix("gitdir:")?.trim()); + let common = std::fs::read_to_string(gitdir.join("commondir")).ok()?; + let common = dunce::canonicalize(gitdir.join(common.trim())).ok()?; + + match common.file_name() { + Some(name) if name == ".git" => common.parent().map(Path::to_path_buf), + _ => None, + } } pub fn from_path(p: impl AsRef<Path>) -> anyhow::Result<String> { @@ -145,6 +156,56 @@ mod tests { ); } + #[test] + fn a_worktree_finds_the_main_checkout_as_its_chronicle_home() { + let d = tempfile::TempDir::new().unwrap(); + let main = d.path().join("repo"); + let git = |args: &[&str]| { + let ok = std::process::Command::new("git") + .args(args) + .current_dir(&main) + .output() + .unwrap() + .status + .success(); + assert!(ok, "git {args:?}"); + }; + std::fs::create_dir_all(main.join("src")).unwrap(); + git(&["init", "-q"]); + git(&[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "--allow-empty", + "-m", + "x", + ]); + let wt = d.path().join("wt"); + git(&["worktree", "add", "-q", wt.to_str().unwrap()]); + std::fs::create_dir_all(wt.join("deep/dir")).unwrap(); + + let main_root = dunce::canonicalize(&main).unwrap(); + assert_eq!(chronicle_home(&wt.join("deep/dir")), Some(main_root)); + assert_eq!(chronicle_home(&main.join("src")), None); + } + + #[test] + fn a_git_file_without_a_common_dir_is_its_own_home() { + // A submodule's `.git` file points into the parent's modules dir, + // which has no `commondir`: it is a project of its own. + let d = tempfile::TempDir::new().unwrap(); + let gitdir = d.path().join("parent/.git/modules/sub"); + std::fs::create_dir_all(&gitdir).unwrap(); + let sub = d.path().join("parent/sub"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join(".git"), format!("gitdir: {}\n", gitdir.display())).unwrap(); + + assert_eq!(chronicle_home(&sub), None); + } + #[test] fn dot_git_file_in_worktree_root_is_a_boundary() { // Worktrees have a `.git` *file* (not a dir) at their root. diff --git a/crates/tj-core/src/session_state.rs b/crates/tj-core/src/session_state.rs index 0f37c7a..23e7482 100644 --- a/crates/tj-core/src/session_state.rs +++ b/crates/tj-core/src/session_state.rs @@ -58,6 +58,7 @@ pub fn session_state( project_hash: &str, session_id: Option<&str>, prefer: Option<&str>, + chr: &crate::chronicle::Chronicle, ) -> anyhow::Result<SessionState> { let open_tasks = conn.query_row( "SELECT COUNT(*) FROM tasks WHERE project_hash = ?1 AND status = 'open'", @@ -80,8 +81,11 @@ pub fn session_state( (None, Some(sid)) => crate::db::active_task_for_session(conn, project_hash, sid)?, (None, None) => None, }; - let active = active_id.map(|id| active_task(conn, &id)).transpose()?; + let active = active_id + .map(|id| active_task(conn, &id, chr)) + .transpose()?; let archive = crate::archive::gaps( + chr, conn, project_hash, active.as_ref().map(|a| a.task_id.as_str()), @@ -96,7 +100,11 @@ pub fn session_state( }) } -fn active_task(conn: &Connection, task_id: &str) -> anyhow::Result<ActiveTask> { +fn active_task( + conn: &Connection, + task_id: &str, + chr: &crate::chronicle::Chronicle, +) -> anyhow::Result<ActiveTask> { let (title, goal) = conn.query_row( "SELECT title, goal FROM tasks WHERE task_id = ?1", [task_id], @@ -143,7 +151,13 @@ fn active_task(conn: &Connection, task_id: &str) -> anyhow::Result<ActiveTask> { recent, modules: crate::modules::modules_of_task(conn, task_id)? .into_iter() - .map(|(_, name)| name) + .map(|(id, name)| { + // A worktree's own state has no modules: names live in the home. + crate::modules::get(&chr.home, &chr.home_hash, &id) + .ok() + .flatten() + .map_or(name, |m| m.name) + }) .collect(), }) } @@ -165,6 +179,12 @@ mod tests { crate::db::index_event(conn, &event).unwrap(); } + fn chronicle(dir: &tempfile::TempDir) -> crate::chronicle::Chronicle { + let conn = crate::db::open(dir.path().join("s.sqlite")).unwrap(); + + crate::chronicle::Chronicle::single(conn, "p") + } + // The TempDir rides along so the database outlives the test body. fn conn() -> (tempfile::TempDir, Connection) { let dir = tempfile::tempdir().unwrap(); @@ -188,7 +208,7 @@ mod tests { crate::session_id::stamp_session_id(&mut mine.meta, Some("s1")); let (_d, conn) = journal(&[stars, mine, open_task("tj-b", &[])]); - let state = session_state(&conn, "p", Some("s1"), None).unwrap(); + let state = session_state(&conn, "p", Some("s1"), None, &chronicle(&_d)).unwrap(); assert_eq!( state.active.as_ref().unwrap().modules, @@ -206,7 +226,7 @@ mod tests { write(&conn, "tj-a", EventType::Decision, "Use <=", Some("s1")); write(&conn, "tj-b", EventType::Open, "b", Some("s2")); - let s1 = session_state(&conn, "p", Some("s1"), None).unwrap(); + let s1 = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(); let active = s1.active.expect("s1 has a task"); assert_eq!(active.task_id, "tj-a"); assert_eq!(active.title, "Title of tj-a"); @@ -216,7 +236,7 @@ mod tests { assert_eq!(active.recent[0].kind, "decision"); assert_eq!(s1.open_tasks, 2); - let other = session_state(&conn, "p", Some("s3"), None).unwrap(); + let other = session_state(&conn, "p", Some("s3"), None, &chronicle(&_dir)).unwrap(); assert!(other.active.is_none()); assert_eq!(other.open_tasks, 2); } @@ -228,16 +248,19 @@ mod tests { write(&conn, "tj-sub", EventType::Open, "s", Some("s1")); // Without a preference the newest write wins; with one, the pin holds. - let plain = session_state(&conn, "p", Some("s1"), None).unwrap(); + let plain = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(); assert_eq!(plain.active.unwrap().task_id, "tj-sub"); - let pinned = session_state(&conn, "p", Some("s1"), Some("tj-parent")).unwrap(); + let pinned = + session_state(&conn, "p", Some("s1"), Some("tj-parent"), &chronicle(&_dir)).unwrap(); assert_eq!(pinned.active.unwrap().task_id, "tj-parent"); // A closed or unknown preference falls back to the session's own task. write(&conn, "tj-parent", EventType::Close, "done", Some("s1")); - let closed = session_state(&conn, "p", Some("s1"), Some("tj-parent")).unwrap(); + let closed = + session_state(&conn, "p", Some("s1"), Some("tj-parent"), &chronicle(&_dir)).unwrap(); assert_eq!(closed.active.unwrap().task_id, "tj-sub"); - let unknown = session_state(&conn, "p", Some("s1"), Some("tj-nope")).unwrap(); + let unknown = + session_state(&conn, "p", Some("s1"), Some("tj-nope"), &chronicle(&_dir)).unwrap(); assert_eq!(unknown.active.unwrap().task_id, "tj-sub"); } @@ -266,7 +289,7 @@ mod tests { crate::db::upsert_task_from_event(&conn, &fix, "p").unwrap(); crate::db::index_event(&conn, &fix).unwrap(); - let active = session_state(&conn, "p", Some("s1"), None) + let active = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)) .unwrap() .active .unwrap(); @@ -285,7 +308,7 @@ mod tests { write(&conn, "tj-a", EventType::Open, "a", Some("s1")); write(&conn, "tj-a", EventType::Close, "done", Some("s1")); - let s = session_state(&conn, "p", Some("s1"), None).unwrap(); + let s = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(); assert!(s.active.is_none()); assert_eq!(s.open_tasks, 0); } @@ -295,13 +318,18 @@ mod tests { let (_dir, conn) = conn(); write(&conn, "tj-a", EventType::Open, "a", Some("s1")); - let v = serde_json::to_value(session_state(&conn, "p", Some("s1"), None).unwrap()).unwrap(); + let v = serde_json::to_value( + session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(), + ) + .unwrap(); assert_eq!(v["schema"], "tj-state/1"); assert_eq!(v["session_id"], "s1"); assert_eq!(v["active"]["task_id"], "tj-a"); assert!(v["active"]["recent"].as_array().unwrap().is_empty()); - let none = serde_json::to_value(session_state(&conn, "p", None, None).unwrap()).unwrap(); + let none = + serde_json::to_value(session_state(&conn, "p", None, None, &chronicle(&_dir)).unwrap()) + .unwrap(); assert!(none["active"].is_null()); assert!(none["session_id"].is_null()); } diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 5d91cbd..199f4ca 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -536,9 +536,45 @@ fn append_events(events: &[tj_core::event::Event]) -> anyhow::Result<()> { Ok(()) } -/// Whether this project has a journal yet; read-only tools create nothing without one. +/// Append `events` to the journal at `path`, durably. +fn append_to(path: &Path, events: &[tj_core::event::Event]) -> anyhow::Result<()> { + let mut writer = tj_core::storage::JsonlWriter::open(path)?; + for event in events { + writer.append(event)?; + } + writer.flush_durable()?; + + Ok(()) +} + +/// The project's chronicle: the module map of its repository (a worktree +/// shares the main checkout's) and every journal that links tasks to it. +fn chronicle() -> anyhow::Result<tj_core::chronicle::Chronicle> { + tj_core::chronicle::Chronicle::open(&project_dir()?) +} + +/// Run `f` on the chronicle and on this project's own state. +fn with_chronicle<T>( + f: impl FnOnce(&tj_core::chronicle::Chronicle, &Connection, &str) -> anyhow::Result<T>, +) -> anyhow::Result<T> { + let chr = chronicle()?; + + with_state(|conn, project_hash| f(&chr, conn, project_hash)) +} + +/// Whether this project, or the main checkout whose module map it shares, has +/// a journal yet; read-only tools create nothing without one. fn has_journal() -> anyhow::Result<bool> { - Ok(project_paths()?.1.exists()) + if project_paths()?.1.exists() { + return Ok(true); + } + + Ok( + match tj_core::project_hash::chronicle_home(&project_dir()?) { + Some(home) => resolve_project_paths(&home)?.1.exists(), + None => false, + }, + ) } /// What a new task's reply says about the chronicle: its modules with their @@ -552,10 +588,10 @@ fn new_task_chronicle( Vec<tj_core::modules::Suggestion>, Option<String>, )> { - with_state(|conn, project_hash| { + with_chronicle(|chr, conn, project_hash| { let mut modules = Vec::new(); for id in linked { - if let Some(m) = tj_core::modules::get(conn, project_hash, id)? { + if let Some(m) = tj_core::modules::get(&chr.home, &chr.home_hash, id)? { modules.push(ModuleBrief { module_id: m.module_id, name: m.name, @@ -564,14 +600,11 @@ fn new_task_chronicle( } } let suggested = if linked.is_empty() { - tj_core::modules::suggest(conn, project_hash, text, &[])? + tj_core::modules::suggest(&chr.home, &chr.home_hash, text, &[])? } else { Vec::new() }; - let gaps = tj_core::archive::for_dir( - tj_core::archive::gaps(conn, project_hash, Some(task_id))?, - &project_dir()?, - ); + let gaps = tj_core::archive::gaps(chr, conn, project_hash, Some(task_id))?; Ok((modules, suggested, tj_core::archive::headline(&gaps))) }) @@ -579,13 +612,13 @@ fn new_task_chronicle( /// What a closed task's modules still need: a link, or a fresh state. fn module_reminder( + chr: &tj_core::chronicle::Chronicle, conn: &Connection, - project_hash: &str, task_id: &str, has_notes: bool, ) -> anyhow::Result<Option<String>> { // No map yet: the session start and module_list already say so. - if tj_core::modules::list(conn, project_hash)?.is_empty() { + if tj_core::modules::list(&chr.home, &chr.home_hash)?.is_empty() { return Ok(None); } @@ -911,9 +944,11 @@ impl TaskJournalServer { // Modules are checked before the open event is written: an // unknown id fails the call and leaves no task behind. let resolved = match p.modules.as_deref() { - Some(ids) if !ids.is_empty() => Some(with_state(|conn, hash| { - tj_core::modules::resolve(conn, hash, ids) - })?), + Some(ids) if !ids.is_empty() => { + let chr = chronicle()?; + let r = tj_core::modules::resolve(&chr.home, &chr.home_hash, ids)?; + Some((r, chr)) + } _ => None, }; @@ -937,8 +972,9 @@ impl TaskJournalServer { if let Some(ref r) = loom_ref { event.meta["external"] = serde_json::json!([r]); } - if let Some(r) = &resolved { + if let Some((r, chr)) = &resolved { event.meta["modules"] = serde_json::json!(r.ids); + chr.stamp(&mut event.meta); } tj_core::session_id::stamp_session_id( &mut event.meta, @@ -950,7 +986,10 @@ impl TaskJournalServer { writer.flush_durable()?; // The task is written; what follows only advises, so it never fails the call. - let linked = resolved.as_ref().map(|r| r.ids.clone()).unwrap_or_default(); + let linked = resolved + .as_ref() + .map(|(r, _)| r.ids.clone()) + .unwrap_or_default(); let text = format!( "{} {} {}", p.title, @@ -965,7 +1004,7 @@ impl TaskJournalServer { title: p.title.clone(), modules, suggested_modules, - warnings: resolved.map(|r| r.warnings).unwrap_or_default(), + warnings: resolved.map(|(r, _)| r.warnings).unwrap_or_default(), chronicle, }) }) @@ -1094,6 +1133,11 @@ impl TaskJournalServer { let open_kids; let mut notes = Vec::new(); let mut warnings = Vec::new(); + // Notes go to modules of the repository's shared map. + let chr = match &p.module_notes { + Some(n) if !n.is_empty() => Some(chronicle()?), + _ => None, + }; { let conn = conn_arc .lock() @@ -1121,9 +1165,10 @@ impl TaskJournalServer { // A note to an unknown module fails the close before it is written. for note in p.module_notes.iter().flatten() { + let chr = chr.as_ref().expect("opened when notes are given"); let r = tj_core::modules::resolve( - &conn, - &project_hash, + &chr.home, + &chr.home_hash, std::slice::from_ref(¬e.module), )?; warnings.extend(r.warnings); @@ -1165,6 +1210,9 @@ impl TaskJournalServer { } } event.meta = serde_json::Value::Object(meta); + if let Some(chr) = &chr { + chr.stamp(&mut event.meta); + } tj_core::session_id::stamp_session_id( &mut event.meta, session_id_or_env(p.session_id.as_deref()).as_deref(), @@ -1189,10 +1237,12 @@ impl TaskJournalServer { ) { gaps = report.gaps.into_iter().map(|g| g.detail).collect(); } - module_reminder = - crate::module_reminder(&conn, &project_hash, &p.task_id, !notes.is_empty()) - .ok() - .flatten(); + module_reminder = chronicle() + .and_then(|chr| { + crate::module_reminder(&chr, &conn, &p.task_id, !notes.is_empty()) + }) + .ok() + .flatten(); } Ok((open_kids, gaps, module_reminder, warnings)) }) @@ -1229,14 +1279,11 @@ impl TaskJournalServer { })); } - with_state(|conn, project_hash| { - let gaps = tj_core::archive::for_dir( - tj_core::archive::gaps(conn, project_hash, None)?, - &project_dir()?, - ); + with_chronicle(|chr, conn, project_hash| { + let gaps = tj_core::archive::gaps(chr, conn, project_hash, None)?; Ok(Json(ModuleListResult { - modules: tj_core::modules::list(conn, project_hash)?, + modules: tj_core::modules::map(chr)?, chronicle: tj_core::archive::headline(&gaps), gaps, })) @@ -1264,9 +1311,7 @@ impl TaskJournalServer { ); } - let text = with_state(|conn, project_hash| { - tj_core::modules::page(conn, project_hash, &p.module_id) - })?; + let text = tj_core::modules::page(&chronicle()?, &p.module_id)?; Ok(Json(ModulePageResult { module_id: p.module_id, @@ -1298,7 +1343,11 @@ impl TaskJournalServer { }; let event = tj_core::modules::module_event(&p.module_id, &fields)?; - let created = with_state(|conn, project_hash| { + // Modules live in the repository's home journal, also when + // saved from a worktree. + let chr = chronicle()?; + let (conn, project_hash) = (&chr.home, chr.home_hash.as_str()); + let created = { let exists = tj_core::modules::get(conn, project_hash, &p.module_id)?.is_some(); if !exists && fields.name.is_none() { anyhow::bail!("module {} is new: pass `name` too", p.module_id); @@ -1312,9 +1361,9 @@ impl TaskJournalServer { } } - Ok(!exists) - })?; - append_events(&[event])?; + !exists + }; + append_to(&chr.home_events(), &[event])?; Ok(Json(ModuleSaveResult { module_id: p.module_id, @@ -1337,7 +1386,7 @@ impl TaskJournalServer { traced_tool("module_link", async move { run_blocking(move || { let mut warnings = Vec::new(); - let events = with_state(|conn, project_hash| { + let events = with_chronicle(|chr, conn, _| { let mut events = Vec::new(); for link in &p.links { if !tj_core::db::task_exists(conn, &link.task_id)? { @@ -1346,16 +1395,15 @@ impl TaskJournalServer { for id in &link.remove { tj_core::modules::validate_id(id)?; } - let add = tj_core::modules::resolve(conn, project_hash, &link.add)?; + let add = tj_core::modules::resolve(&chr.home, &chr.home_hash, &link.add)?; warnings.extend(add.warnings); if add.ids.is_empty() && link.remove.is_empty() { continue; } - events.push(tj_core::modules::link_event( - &link.task_id, - &add.ids, - &link.remove, - )); + let mut event = + tj_core::modules::link_event(&link.task_id, &add.ids, &link.remove); + chr.stamp(&mut event.meta); + events.push(event); } Ok(events) @@ -1390,8 +1438,9 @@ impl TaskJournalServer { } let limit = p.limit.unwrap_or(20).min(100); - let (total_unlinked, candidates) = with_state(|conn, project_hash| { + let (total_unlinked, candidates) = with_chronicle(|chr, conn, project_hash| { tj_core::modules::backfill_candidates( + chr, conn, project_hash, limit, diff --git a/plugin/skills/task-journal/SKILL.md b/plugin/skills/task-journal/SKILL.md index d1fcec1..d05bc22 100644 --- a/plugin/skills/task-journal/SKILL.md +++ b/plugin/skills/task-journal/SKILL.md @@ -115,7 +115,8 @@ The project is a map of **modules**: parts of the system by meaning — not fold tickets. Every task belongs to one or more modules, and each module keeps a page: how it works now (`state`), what is decided, rejected and constrained there across all its tasks, and every task in order. Six months later, `module_page` is how you learn a part of the -system before touching it. +system before touching it. A repository has one map: in a git worktree the same modules +apply, and its tasks join their history. - **Start:** `task_create(..., modules=["stars"])`. Not sure which? The reply carries `suggested_modules` — link with `module_link`, or create a module with `module_save`. From c142c8c69960dd9875e3581dab0d5d56f1ff8281 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 18:01:02 +0400 Subject: [PATCH 20/23] fix(core): accept a worktree's chronicle home only when the repository lists it back --- crates/tj-core/src/project_hash.rs | 46 ++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/tj-core/src/project_hash.rs b/crates/tj-core/src/project_hash.rs index 71de441..0c3e94c 100644 --- a/crates/tj-core/src/project_hash.rs +++ b/crates/tj-core/src/project_hash.rs @@ -38,15 +38,28 @@ pub fn project_root(start: &Path) -> PathBuf { /// where the project chronicle (the module map) lives. `None` when `dir` is /// not in a linked worktree: the project is its own home. A worktree's root /// holds a `.git` file naming its gitdir, whose `commondir` leads to the -/// main repository's `.git`; a submodule's gitdir has no `commondir`. +/// main repository's `.git`, which lists the worktree in turn; a submodule's +/// gitdir has no `commondir`. pub fn chronicle_home(dir: &Path) -> Option<PathBuf> { let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); let root = project_root(&dir); - let pointer = std::fs::read_to_string(root.join(".git")).ok()?; - let gitdir = root.join(pointer.trim().strip_prefix("gitdir:")?.trim()); + let dot_git = dunce::canonicalize(root.join(".git")).ok()?; + let pointer = std::fs::read_to_string(&dot_git).ok()?; + let gitdir = + dunce::canonicalize(root.join(pointer.trim().strip_prefix("gitdir:")?.trim())).ok()?; let common = std::fs::read_to_string(gitdir.join("commondir")).ok()?; let common = dunce::canonicalize(gitdir.join(common.trim())).ok()?; + // Git's own check, both ways: the gitdir sits in the repository's + // `worktrees/` and names this worktree back. Without it, any folder + // with a forged `.git` file could join another repository's chronicle. + let back = std::fs::read_to_string(gitdir.join("gitdir")).ok()?; + let points_back = dunce::canonicalize(gitdir.join(back.trim())).ok()? == dot_git; + let is_listed = gitdir.parent() == Some(common.join("worktrees").as_path()); + if !points_back || !is_listed || !common.join("HEAD").exists() { + return None; + } + match common.file_name() { Some(name) if name == ".git" => common.parent().map(Path::to_path_buf), _ => None, @@ -192,6 +205,33 @@ mod tests { assert_eq!(chronicle_home(&main.join("src")), None); } + #[test] + fn a_forged_git_file_cannot_join_another_repository() { + // A downloaded folder whose `.git` file points, through a gitdir it + // ships itself, at someone else's repository: git would refuse it, + // because that repository lists no such worktree. So do we. + let d = tempfile::TempDir::new().unwrap(); + let victim = d.path().join("victim"); + std::fs::create_dir_all(victim.join(".git")).unwrap(); + std::fs::write(victim.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let forged = d.path().join("forged"); + let fake = forged.join("fake-gitdir"); + std::fs::create_dir_all(&fake).unwrap(); + std::fs::write( + fake.join("commondir"), + victim.join(".git").display().to_string(), + ) + .unwrap(); + std::fs::write( + fake.join("gitdir"), + forged.join(".git").display().to_string(), + ) + .unwrap(); + std::fs::write(forged.join(".git"), format!("gitdir: {}\n", fake.display())).unwrap(); + + assert_eq!(chronicle_home(&forged), None); + } + #[test] fn a_git_file_without_a_common_dir_is_its_own_home() { // A submodule's `.git` file points into the parent's modules dir, From e97978521bfc84415ee5a252aa0a2f54cc8749ef Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 18:03:10 +0400 Subject: [PATCH 21/23] fix(core): a symlinked .git cannot borrow another worktree's chronicle --- crates/tj-core/src/project_hash.rs | 50 +++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/tj-core/src/project_hash.rs b/crates/tj-core/src/project_hash.rs index 0c3e94c..2746623 100644 --- a/crates/tj-core/src/project_hash.rs +++ b/crates/tj-core/src/project_hash.rs @@ -43,7 +43,15 @@ pub fn project_root(start: &Path) -> PathBuf { pub fn chronicle_home(dir: &Path) -> Option<PathBuf> { let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); let root = project_root(&dir); - let dot_git = dunce::canonicalize(root.join(".git")).ok()?; + // A plain file only: a symlinked `.git` could borrow another worktree. + let dot_git = root.join(".git"); + if !std::fs::symlink_metadata(&dot_git) + .ok()? + .file_type() + .is_file() + { + return None; + } let pointer = std::fs::read_to_string(&dot_git).ok()?; let gitdir = dunce::canonicalize(root.join(pointer.trim().strip_prefix("gitdir:")?.trim())).ok()?; @@ -232,6 +240,46 @@ mod tests { assert_eq!(chronicle_home(&forged), None); } + #[cfg(unix)] + #[test] + fn a_symlinked_git_file_cannot_borrow_a_real_worktree() { + // A folder whose `.git` is a symlink to a real worktree's `.git` + // file would pass the two-way check by borrowing that worktree. + let d = tempfile::TempDir::new().unwrap(); + let main = d.path().join("repo"); + std::fs::create_dir_all(&main).unwrap(); + let git = |args: &[&str]| { + let ok = std::process::Command::new("git") + .args(args) + .current_dir(&main) + .output() + .unwrap() + .status + .success(); + assert!(ok, "git {args:?}"); + }; + git(&["init", "-q"]); + git(&[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "--allow-empty", + "-m", + "x", + ]); + let wt = d.path().join("wt"); + git(&["worktree", "add", "-q", wt.to_str().unwrap()]); + let forged = d.path().join("forged"); + std::fs::create_dir_all(&forged).unwrap(); + std::os::unix::fs::symlink(wt.join(".git"), forged.join(".git")).unwrap(); + + assert!(chronicle_home(&wt).is_some()); + assert_eq!(chronicle_home(&forged), None); + } + #[test] fn a_git_file_without_a_common_dir_is_its_own_home() { // A submodule's `.git` file points into the parent's modules dir, From e0fe78f34f7e20a99a4163d8bb0fe91840b47633 Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 18:21:32 +0400 Subject: [PATCH 22/23] fix(mcp): readable module reminder after closing a task --- crates/tj-mcp/src/main.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 199f4ca..ec7b219 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -634,12 +634,14 @@ fn module_reminder( is kept (module_list shows the map)." .to_string(), (false, false) => format!( - "Closed without module_notes, so the history of {ids} has no line on it — make sure \ - each module's state says what changed: module_save(state=...)." + "Closed without module_notes, so the history of {ids} has no line on it — check \ + each module's state and, if this task changed how it works, rewrite it with \ + module_save(state=...)." + ), + (false, true) => format!( + "Check the state of {ids}: if this task changed how it works, rewrite it with \ + module_save(state=...)." ), - (false, true) => { - format!("If {ids} work differently now, rewrite the state with module_save(state=...).") - } })) } @@ -2711,14 +2713,12 @@ mod tests { .await .unwrap() .0; - assert!( - closed - .module_reminder - .as_deref() - .unwrap_or("") - .contains("module_save(state"), - "{:?}", - closed.module_reminder + assert_eq!( + closed.module_reminder.as_deref(), + Some( + "Check the state of mcp-flow: if this task changed how it works, rewrite it with \ + module_save(state=...)." + ), ); let page = server From 49629e87e04960a5def8f6790db0f0570ead4b4f Mon Sep 17 00:00:00 2001 From: Mher Shahinyan <shahinyanm@gmail.com> Date: Sun, 4 Oct 2026 18:35:20 +0400 Subject: [PATCH 23/23] fix: the chronicle visits worktree journals one at a time and only in module views; a broken member is skipped; migrate-project carries the registry --- crates/tj-cli/src/main.rs | 35 +++++- crates/tj-cli/tests/cli.rs | 39 +++++++ crates/tj-core/src/archive.rs | 10 +- crates/tj-core/src/chronicle.rs | 162 ++++++++++++++++++++++------ crates/tj-core/src/db.rs | 2 +- crates/tj-core/src/modules.rs | 94 +++++++++------- crates/tj-core/src/session_state.rs | 96 ++++++++++++----- crates/tj-mcp/src/main.rs | 5 +- 8 files changed, 341 insertions(+), 102 deletions(-) diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index d0b9be7..2abf7b3 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -480,6 +480,39 @@ fn run_migrate_project(from: &std::path::Path, to: &std::path::Path, force: bool } } + // The chronicle registry: a moved home keeps the worktree journals its + // module history comes from, and a moved member stays listed under its home. + let chronicle_dir = tj_core::paths::data_dir()?.join("chronicle"); + if chronicle_dir.is_dir() { + let from_registry = chronicle_dir.join(format!("{from_hash}.members")); + if from_registry.exists() { + let to_registry = chronicle_dir.join(format!("{to_hash}.members")); + let mut members = std::fs::read_to_string(&to_registry).unwrap_or_default(); + members.push_str(&std::fs::read_to_string(&from_registry)?); + std::fs::write(&to_registry, members)?; + std::fs::remove_file(&from_registry)?; + moved.push(to_registry.display().to_string()); + } + for entry in std::fs::read_dir(&chronicle_dir)? { + let path = entry?.path(); + let text = std::fs::read_to_string(&path)?; + if text.lines().any(|l| l.trim() == from_hash) { + let renamed: String = text + .lines() + .map(|l| { + if l.trim() == from_hash { + to_hash.as_str() + } else { + l + } + }) + .map(|l| format!("{l}\n")) + .collect(); + std::fs::write(&path, renamed)?; + } + } + } + // The global cross-project index keys its rows by project_hash too. let memory_path = tj_core::paths::memory_db()?; if memory_path.exists() { @@ -2027,7 +2060,7 @@ fn real_main() -> Result<()> { &project_hash, session.as_deref(), prefer.as_deref(), - &tj_core::chronicle::Chronicle::open(&cwd)?, + tj_core::chronicle::Chronicle::open(&cwd).ok().as_ref(), )? } else { tj_core::session_state::SessionState { diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index abdd636..20c983e 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -1025,6 +1025,45 @@ fn migrate_project_rekeys_embeddings_and_dream_state() { } } +#[test] +fn migrate_project_carries_the_chronicle_registry() { + // The registry lists the worktree journals a home's module history comes + // from; a moved home must keep it, and a moved member must stay listed. + let xdg = assert_fs::TempDir::new().unwrap(); + let (proj_a, proj_b, from_hash, to_hash) = two_projects(); + let chronicle = xdg.path().join("task-journal").join("chronicle"); + std::fs::create_dir_all(&chronicle).unwrap(); + std::fs::write( + chronicle.join(format!("{from_hash}.members")), + "aaaaaaaaaaaaaaaa\n", + ) + .unwrap(); + std::fs::write( + chronicle.join("bbbbbbbbbbbbbbbb.members"), + format!("{from_hash}\n"), + ) + .unwrap(); + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", xdg.path()) + .current_dir(proj_a.path()) + .args(["create", "Move me"]) + .assert() + .success(); + + migrate(xdg.path(), proj_a.path(), proj_b.path(), false); + + assert!(!chronicle.join(format!("{from_hash}.members")).exists()); + assert_eq!( + std::fs::read_to_string(chronicle.join(format!("{to_hash}.members"))).unwrap(), + "aaaaaaaaaaaaaaaa\n" + ); + assert_eq!( + std::fs::read_to_string(chronicle.join("bbbbbbbbbbbbbbbb.members")).unwrap(), + format!("{to_hash}\n") + ); +} + #[test] fn migrate_project_rekeys_only_its_rows_in_global_memory() { let xdg = assert_fs::TempDir::new().unwrap(); diff --git a/crates/tj-core/src/archive.rs b/crates/tj-core/src/archive.rs index 383b9ab..6664700 100644 --- a/crates/tj-core/src/archive.rs +++ b/crates/tj-core/src/archive.rs @@ -62,7 +62,7 @@ pub fn headline(gaps: &[Gap]) -> Option<String> { /// What the chronicle is missing, most important first, as seen from the /// journal `conn` (`project_hash`): the map comes from the chronicle's home, -/// tasks from this journal, and a module's lag from every journal. +/// tasks from this journal, and a module's lag from the home and this journal. /// `active_task` is the session's task, checked for a module of its own. pub fn gaps( chr: &crate::chronicle::Chronicle, @@ -97,8 +97,14 @@ pub fn gaps( let mut stale = Vec::new(); for m in modules.iter().filter(|m| m.status == "active") { + // The home and this journal only: a session's gaps stay cheap however + // many worktrees the repository had. Module pages count them all. + let mut journals = vec![&chr.home]; + if chr.member_hash.is_some() { + journals.push(conn); + } let mut closed_since = 0; - for journal in chr.journals() { + for journal in journals { closed_since += journal.query_row( "SELECT COUNT(*) FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id AND t.status = 'closed' diff --git a/crates/tj-core/src/chronicle.rs b/crates/tj-core/src/chronicle.rs index 0a2cde0..f86a5dd 100644 --- a/crates/tj-core/src/chronicle.rs +++ b/crates/tj-core/src/chronicle.rs @@ -17,15 +17,16 @@ pub struct Chronicle { pub home_hash: String, /// The home's state: the modules, and the home's own tasks. pub home: Connection, - /// The states of the other journals that may hold linked tasks. - pub members: Vec<Connection>, /// The current project's hash when it is a member (a worktree). pub member_hash: Option<String>, + /// The other journals known to link tasks to the home's modules. Opened + /// one at a time, and only by the views that gather the whole history. + members: Vec<String>, data: PathBuf, } impl Chronicle { - /// The chronicle of the project at `dir`, every journal ingested. + /// The chronicle of the project at `dir`: its home's state, caught up. pub fn open(dir: &Path) -> anyhow::Result<Self> { Self::open_in(&crate::paths::data_dir()?, dir) } @@ -38,23 +39,17 @@ impl Chronicle { None => current.clone(), }; let member_hash = (home_hash != current).then_some(current); - if let Some(member) = &member_hash { - register(data, &home_hash, member)?; - } - let home = open_journal(data, &home_hash)?; - let mut members = Vec::new(); - for hash in member_hashes(data, &home_hash)? { - if hash != home_hash && data.join("events").join(format!("{hash}.jsonl")).exists() { - members.push(open_journal(data, &hash)?); - } - } + let members = member_hashes(data, &home_hash)? + .into_iter() + .filter(|h| *h != home_hash) + .collect(); Ok(Self { home_hash, home, - members, member_hash, + members, data: data.to_path_buf(), }) } @@ -64,15 +59,35 @@ impl Chronicle { Self { home_hash: hash.to_string(), home: conn, - members: Vec::new(), member_hash: None, + members: Vec::new(), data: PathBuf::new(), } } - /// Every state that holds tasks, the home first. - pub fn journals(&self) -> impl Iterator<Item = &Connection> { - std::iter::once(&self.home).chain(self.members.iter()) + pub fn member_hashes(&self) -> &[String] { + &self.members + } + + /// Visit each member journal's state in turn: opened, caught up with its + /// journal and closed again, so a repository with hundreds of worktrees + /// holds one at a time. A member that fails is skipped, never fatal. + pub fn for_each_member(&self, mut visit: impl FnMut(&Connection) -> anyhow::Result<()>) { + for hash in &self.members { + if !self + .data + .join("events") + .join(format!("{hash}.jsonl")) + .exists() + { + continue; + } + + let result = open_journal(&self.data, hash).and_then(|conn| visit(&conn)); + if let Err(e) = result { + tracing::warn!(member = hash.as_str(), "skipping a chronicle member: {e:#}"); + } + } } /// The home's journal file: where module events go. @@ -82,19 +97,21 @@ impl Chronicle { .join(format!("{}.jsonl", self.home_hash)) } - /// Mark an event a member writes about modules with its home, so the - /// home finds the member again even without the registry. + /// Mark an event a member writes about modules with its home, and list + /// the member under its home: from now on its tasks join the history. pub fn stamp(&self, meta: &mut serde_json::Value) { - if self.member_hash.is_some() { - meta[HOME_KEY] = serde_json::Value::String(self.home_hash.clone()); - } - } - - /// Re-read the home's journal after writing to it. - pub fn refresh_home(&self) -> anyhow::Result<()> { - crate::db::ingest_new_events(&self.home, self.home_events(), &self.home_hash)?; + let Some(member) = &self.member_hash else { + return; + }; - Ok(()) + meta[HOME_KEY] = serde_json::Value::String(self.home_hash.clone()); + // The mark is in the journal, so a lost registration is rebuilt. + if let Err(e) = register(&self.data, &self.home_hash, member) { + tracing::warn!( + member = member.as_str(), + "could not register a chronicle member: {e:#}" + ); + } } } @@ -167,7 +184,18 @@ fn scan(data: &Path, home: &str) -> anyhow::Result<Vec<String>> { for entry in entries { let path = entry?.path(); let is_journal = path.extension().is_some_and(|x| x == "jsonl"); - if is_journal && std::fs::read_to_string(&path).is_ok_and(|t| t.contains(&needle)) { + let is_member = is_journal + && std::fs::read_to_string(&path).is_ok_and(|text| { + // Only the mark a member writes counts: `meta.chronicle_home` + // of an event, not the same text inside any other value. + text.lines() + .filter(|line| line.contains(&needle)) + .any(|line| { + serde_json::from_str::<serde_json::Value>(line) + .is_ok_and(|e| e["meta"][HOME_KEY] == home) + }) + }); + if is_member { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { out.push(stem.to_string()); } @@ -350,7 +378,79 @@ mod tests { assert_eq!(chr.home_hash, home); assert!(chr.member_hash.is_none()); - assert!(chr.members.is_empty()); + assert!(chr.member_hashes().is_empty()); + } + + fn registry_of(r: &Repo) -> String { + let home = crate::project_hash::from_path(&r.main).unwrap(); + + std::fs::read_to_string(r.data.join("chronicle").join(format!("{home}.members"))) + .unwrap_or_default() + } + + #[test] + fn a_worktree_registers_only_once_it_links_a_task() { + // Every Loom task gets a worktree; most never link anything. Only a + // worktree whose tasks join the history is worth visiting. + let r = repo(); + write(&r.data, &r.main, &[stars()]); + let member = crate::project_hash::from_path(&r.worktree).unwrap(); + + let chr = Chronicle::open_in(&r.data, &r.worktree).unwrap(); + assert!( + !registry_of(&r).contains(&member), + "registered on a mere open" + ); + + chr.stamp(&mut serde_json::json!({})); + assert!(registry_of(&r).contains(&member)); + } + + #[test] + fn a_broken_member_is_skipped_not_fatal() { + let r = populated(); + Chronicle::open_in(&r.data, &r.worktree) + .unwrap() + .stamp(&mut serde_json::json!({})); + let bogus = "deadbeefdeadbeef"; + std::fs::write(r.data.join("events").join(format!("{bogus}.jsonl")), "{}\n").unwrap(); + std::fs::write( + r.data.join("state").join(format!("{bogus}.sqlite")), + "not a database", + ) + .unwrap(); + let home = crate::project_hash::from_path(&r.main).unwrap(); + let registry = r.data.join("chronicle").join(format!("{home}.members")); + std::fs::write( + ®istry, + format!("{}{bogus}\n", std::fs::read_to_string(®istry).unwrap()), + ) + .unwrap(); + + let chr = Chronicle::open_in(&r.data, &r.main).unwrap(); + + assert!(crate::modules::page(&chr, "stars") + .unwrap() + .contains("tj-wt")); + assert_eq!(crate::modules::map(&chr).unwrap()[0].task_count, 2); + } + + #[test] + fn a_mark_inside_free_text_does_not_join_a_chronicle() { + // An agent can put any JSON in a decision's alternatives; only the + // top-level mark a member writes counts. + let r = repo(); + let home = write(&r.data, &r.main, &[stars()]); + let mut open = open_task("tj-stranger", &["stars"]); + open.meta["alternatives"] = serde_json::json!([{ HOME_KEY: home }]); + let stranger = tempfile::TempDir::new().unwrap(); + write(&r.data, stranger.path(), &[open]); + + let chr = Chronicle::open_in(&r.data, &r.main).unwrap(); + + assert!(!crate::modules::page(&chr, "stars") + .unwrap() + .contains("tj-stranger")); } #[test] diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 58839ec..88f008a 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -1140,7 +1140,7 @@ fn clear_search_fts(conn: &Connection, project_hash: &str) -> anyhow::Result<()> /// table; by rowid, re-indexing an event replaces its row in O(log n). // ponytail: two ids hashing alike (odds ~n²/2^64) would share one search row; // a mapping table removes that if it ever matters. -fn fts_rowid(event_id: &str) -> i64 { +pub(crate) fn fts_rowid(event_id: &str) -> i64 { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in event_id.bytes() { hash ^= u64::from(byte); diff --git a/crates/tj-core/src/modules.rs b/crates/tj-core/src/modules.rs index 4a15009..38b0226 100644 --- a/crates/tj-core/src/modules.rs +++ b/crates/tj-core/src/modules.rs @@ -350,7 +350,7 @@ pub fn list(conn: &Connection, project_hash: &str) -> anyhow::Result<Vec<Module> pub fn map(chr: &crate::chronicle::Chronicle) -> anyhow::Result<Vec<Module>> { let mut modules = list(&chr.home, &chr.home_hash)?; - for conn in &chr.members { + chr.for_each_member(|conn| { let mut stmt = conn.prepare( "SELECT tm.module_id, COUNT(*), MAX(t.last_event_at) FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id @@ -372,7 +372,9 @@ pub fn map(chr: &crate::chronicle::Chronicle) -> anyhow::Result<Vec<Module>> { } } } - } + + Ok(()) + }); Ok(modules) } @@ -692,17 +694,20 @@ const FULL_HISTORY: usize = 30; const SHORT_OUTCOME: usize = 80; struct HistoryRow { - /// Which of the chronicle's journals holds the task. - journal: usize, task_id: String, title: String, status: String, at: String, outcome: Option<String>, note: Option<String>, + /// The task's live decisions, rejections and constraints, one line each, + /// read while its journal is open. + decisions: Vec<String>, + rejected: Vec<String>, + constraints: Vec<String>, } -fn history(conn: &Connection, journal: usize, ids: &[String]) -> anyhow::Result<Vec<HistoryRow>> { +fn history(conn: &Connection, ids: &[String]) -> anyhow::Result<Vec<HistoryRow>> { let mut stmt = conn.prepare( "SELECT t.task_id, t.title, t.status, COALESCE(t.closed_at, t.last_event_at), t.outcome, (SELECT group_concat(n.text, ' / ') FROM module_notes n @@ -716,13 +721,15 @@ fn history(conn: &Connection, journal: usize, ids: &[String]) -> anyhow::Result< for id in ids { let part = stmt.query_map([id], |r| { Ok(HistoryRow { - journal, task_id: r.get(0)?, title: r.get(1)?, status: r.get(2)?, at: r.get(3)?, outcome: r.get(4)?, note: r.get(5)?, + decisions: Vec::new(), + rejected: Vec::new(), + constraints: Vec::new(), }) })?; for row in part { @@ -733,21 +740,47 @@ fn history(conn: &Connection, journal: usize, ids: &[String]) -> anyhow::Result< } } + let entry = |text: &str, marker: &str, task: &str| { + format!("- {}{marker} ({task})", one_line(text, ENTRY_CHARS)) + }; + for r in &mut rows { + for d in crate::pack::active_decisions(conn, &r.task_id)? { + if !crate::pack::is_noise(&d.text) { + r.decisions.push(entry(&d.text, d.marker(), &r.task_id)); + } + } + for x in crate::pack::rejections(conn, &r.task_id)? { + if !crate::pack::is_noise(&x.text) { + r.rejected.push(entry(&x.text, x.marker(), &r.task_id)); + } + } + for c in texts_of(conn, &r.task_id, "constraint")? { + r.constraints.push(entry(&c, "", &r.task_id)); + } + } + Ok(rows) } /// The task's live entries of one type, newest first: not corrected, not bookkeeping. fn texts_of(conn: &Connection, task_id: &str, kind: &str) -> anyhow::Result<Vec<String>> { - let mut stmt = conn.prepare( - "SELECT f.text FROM events_index ei JOIN search_fts f ON f.event_id = ei.event_id - WHERE ei.task_id = ?1 AND ei.type = ?2 AND ei.corrected_by IS NULL AND ei.bookkeeping = 0 - ORDER BY ei.timestamp DESC", + let mut ids = conn.prepare( + "SELECT event_id FROM events_index + WHERE task_id = ?1 AND type = ?2 AND corrected_by IS NULL AND bookkeeping = 0 + ORDER BY timestamp DESC", )?; - let rows = stmt - .query_map(rusqlite::params![task_id, kind], |r| r.get(0))? - .collect::<Result<_, _>>()?; + // By rowid: `event_id` is not indexed in the FTS table, so a join scans it. + let mut text = conn.prepare("SELECT text FROM search_fts WHERE rowid = ?1")?; - Ok(rows) + let mut out = Vec::new(); + for event_id in ids.query_map(rusqlite::params![task_id, kind], |r| r.get::<_, String>(0))? { + let rowid = crate::db::fts_rowid(&event_id?); + if let Some(t) = text.query_row([rowid], |r| r.get(0)).optional()? { + out.push(t); + } + } + + Ok(out) } fn day(at: &str) -> &str { @@ -808,12 +841,13 @@ pub fn page(chr: &crate::chronicle::Chronicle, module_id: &str) -> anyhow::Resul })? { ids.push(id?); } - // The module's tasks from every journal of the repository. - let journals: Vec<&Connection> = chr.journals().collect(); - let mut rows = Vec::new(); - for (i, journal) in journals.iter().enumerate() { - rows.extend(history(journal, i, &ids)?); - } + // The module's tasks from every journal of the repository: the home's, + // then each member's while it is open. + let mut rows = history(conn, &ids)?; + chr.for_each_member(|member| { + rows.extend(history(member, &ids)?); + Ok(()) + }); let mut seen = std::collections::HashSet::new(); rows.retain(|r| seen.insert(r.task_id.clone())); rows.sort_by(|a, b| b.at.cmp(&a.at)); @@ -861,24 +895,10 @@ pub fn page(chr: &crate::chronicle::Chronicle, module_id: &str) -> anyhow::Resul } let (mut decisions, mut rejected, mut constraints) = (Vec::new(), Vec::new(), Vec::new()); - let entry = |text: &str, marker: &str, task: &str| { - format!("- {}{marker} ({task})", one_line(text, ENTRY_CHARS)) - }; for r in &rows { - let conn = journals[r.journal]; - for d in crate::pack::active_decisions(conn, &r.task_id)? { - if !crate::pack::is_noise(&d.text) { - decisions.push(entry(&d.text, d.marker(), &r.task_id)); - } - } - for x in crate::pack::rejections(conn, &r.task_id)? { - if !crate::pack::is_noise(&x.text) { - rejected.push(entry(&x.text, x.marker(), &r.task_id)); - } - } - for c in texts_of(conn, &r.task_id, "constraint")? { - constraints.push(entry(&c, "", &r.task_id)); - } + decisions.extend(r.decisions.iter().cloned()); + rejected.extend(r.rejected.iter().cloned()); + constraints.extend(r.constraints.iter().cloned()); } push_section(&mut out, "Active decisions", &decisions, DECISION_ITEMS); push_section(&mut out, "Rejected", &rejected, OTHER_ITEMS); diff --git a/crates/tj-core/src/session_state.rs b/crates/tj-core/src/session_state.rs index 23e7482..2be7c54 100644 --- a/crates/tj-core/src/session_state.rs +++ b/crates/tj-core/src/session_state.rs @@ -58,7 +58,7 @@ pub fn session_state( project_hash: &str, session_id: Option<&str>, prefer: Option<&str>, - chr: &crate::chronicle::Chronicle, + chr: Option<&crate::chronicle::Chronicle>, ) -> anyhow::Result<SessionState> { let open_tasks = conn.query_row( "SELECT COUNT(*) FROM tasks WHERE project_hash = ?1 AND status = 'open'", @@ -84,12 +84,18 @@ pub fn session_state( let active = active_id .map(|id| active_task(conn, &id, chr)) .transpose()?; - let archive = crate::archive::gaps( - chr, - conn, - project_hash, - active.as_ref().map(|a| a.task_id.as_str()), - )?; + // The chronicle is advice: a map that cannot be read leaves it empty. + let archive = chr + .and_then(|chr| { + crate::archive::gaps( + chr, + conn, + project_hash, + active.as_ref().map(|a| a.task_id.as_str()), + ) + .ok() + }) + .unwrap_or_default(); Ok(SessionState { schema: SCHEMA, @@ -103,7 +109,7 @@ pub fn session_state( fn active_task( conn: &Connection, task_id: &str, - chr: &crate::chronicle::Chronicle, + chr: Option<&crate::chronicle::Chronicle>, ) -> anyhow::Result<ActiveTask> { let (title, goal) = conn.query_row( "SELECT title, goal FROM tasks WHERE task_id = ?1", @@ -153,10 +159,12 @@ fn active_task( .into_iter() .map(|(id, name)| { // A worktree's own state has no modules: names live in the home. - crate::modules::get(&chr.home, &chr.home_hash, &id) - .ok() - .flatten() - .map_or(name, |m| m.name) + chr.and_then(|chr| { + crate::modules::get(&chr.home, &chr.home_hash, &id) + .ok() + .flatten() + }) + .map_or(name, |m| m.name) }) .collect(), }) @@ -208,7 +216,7 @@ mod tests { crate::session_id::stamp_session_id(&mut mine.meta, Some("s1")); let (_d, conn) = journal(&[stars, mine, open_task("tj-b", &[])]); - let state = session_state(&conn, "p", Some("s1"), None, &chronicle(&_d)).unwrap(); + let state = session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_d))).unwrap(); assert_eq!( state.active.as_ref().unwrap().modules, @@ -219,6 +227,19 @@ mod tests { assert_eq!(json["active"]["modules"][0], "Stars"); } + #[test] + fn without_a_chronicle_the_state_still_names_the_task() { + // The chronicle is advice: a map that cannot be read must not take + // the status line down with it. + let (_dir, conn) = conn(); + write(&conn, "tj-a", EventType::Open, "a", Some("s1")); + + let state = session_state(&conn, "p", Some("s1"), None, None).unwrap(); + + assert_eq!(state.active.unwrap().task_id, "tj-a"); + assert!(state.archive.is_empty()); + } + #[test] fn active_task_is_the_sessions_own_not_the_newest() { let (_dir, conn) = conn(); @@ -226,7 +247,7 @@ mod tests { write(&conn, "tj-a", EventType::Decision, "Use <=", Some("s1")); write(&conn, "tj-b", EventType::Open, "b", Some("s2")); - let s1 = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(); + let s1 = session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_dir))).unwrap(); let active = s1.active.expect("s1 has a task"); assert_eq!(active.task_id, "tj-a"); assert_eq!(active.title, "Title of tj-a"); @@ -236,7 +257,7 @@ mod tests { assert_eq!(active.recent[0].kind, "decision"); assert_eq!(s1.open_tasks, 2); - let other = session_state(&conn, "p", Some("s3"), None, &chronicle(&_dir)).unwrap(); + let other = session_state(&conn, "p", Some("s3"), None, Some(&chronicle(&_dir))).unwrap(); assert!(other.active.is_none()); assert_eq!(other.open_tasks, 2); } @@ -248,19 +269,37 @@ mod tests { write(&conn, "tj-sub", EventType::Open, "s", Some("s1")); // Without a preference the newest write wins; with one, the pin holds. - let plain = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(); + let plain = session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_dir))).unwrap(); assert_eq!(plain.active.unwrap().task_id, "tj-sub"); - let pinned = - session_state(&conn, "p", Some("s1"), Some("tj-parent"), &chronicle(&_dir)).unwrap(); + let pinned = session_state( + &conn, + "p", + Some("s1"), + Some("tj-parent"), + Some(&chronicle(&_dir)), + ) + .unwrap(); assert_eq!(pinned.active.unwrap().task_id, "tj-parent"); // A closed or unknown preference falls back to the session's own task. write(&conn, "tj-parent", EventType::Close, "done", Some("s1")); - let closed = - session_state(&conn, "p", Some("s1"), Some("tj-parent"), &chronicle(&_dir)).unwrap(); + let closed = session_state( + &conn, + "p", + Some("s1"), + Some("tj-parent"), + Some(&chronicle(&_dir)), + ) + .unwrap(); assert_eq!(closed.active.unwrap().task_id, "tj-sub"); - let unknown = - session_state(&conn, "p", Some("s1"), Some("tj-nope"), &chronicle(&_dir)).unwrap(); + let unknown = session_state( + &conn, + "p", + Some("s1"), + Some("tj-nope"), + Some(&chronicle(&_dir)), + ) + .unwrap(); assert_eq!(unknown.active.unwrap().task_id, "tj-sub"); } @@ -289,7 +328,7 @@ mod tests { crate::db::upsert_task_from_event(&conn, &fix, "p").unwrap(); crate::db::index_event(&conn, &fix).unwrap(); - let active = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)) + let active = session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_dir))) .unwrap() .active .unwrap(); @@ -308,7 +347,7 @@ mod tests { write(&conn, "tj-a", EventType::Open, "a", Some("s1")); write(&conn, "tj-a", EventType::Close, "done", Some("s1")); - let s = session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(); + let s = session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_dir))).unwrap(); assert!(s.active.is_none()); assert_eq!(s.open_tasks, 0); } @@ -319,7 +358,7 @@ mod tests { write(&conn, "tj-a", EventType::Open, "a", Some("s1")); let v = serde_json::to_value( - session_state(&conn, "p", Some("s1"), None, &chronicle(&_dir)).unwrap(), + session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_dir))).unwrap(), ) .unwrap(); assert_eq!(v["schema"], "tj-state/1"); @@ -327,9 +366,10 @@ mod tests { assert_eq!(v["active"]["task_id"], "tj-a"); assert!(v["active"]["recent"].as_array().unwrap().is_empty()); - let none = - serde_json::to_value(session_state(&conn, "p", None, None, &chronicle(&_dir)).unwrap()) - .unwrap(); + let none = serde_json::to_value( + session_state(&conn, "p", None, None, Some(&chronicle(&_dir))).unwrap(), + ) + .unwrap(); assert!(none["active"].is_null()); assert!(none["session_id"].is_null()); } diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index ec7b219..a05c145 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -432,7 +432,8 @@ pub struct ModulePageResult { pub struct BackfillParams { /// Tasks per page, newest first (default 20, at most 100). pub limit: Option<usize>, - /// Tasks to skip: page on past the ones you left for later. + /// Tasks to skip: the ones you left unlinked on earlier pages. Linked + /// tasks drop out of the list, so do not count them. pub offset: Option<usize>, } #[derive(Debug, Serialize, schemars::JsonSchema)] @@ -1424,7 +1425,7 @@ impl TaskJournalServer { #[tool( name = "module_backfill_candidates", - description = "Tasks that belong to no module, newest first: id, title, status, goal, outcome, files, and the modules the journal suggests for each. Sort them, confirm with the user, then module_link. `total_unlinked` counts them all; page with `limit` and `offset`." + description = "Tasks that belong to no module, newest first: id, title, status, goal, outcome, files, and the modules the journal suggests for each. Sort them, confirm with the user, then module_link. `total_unlinked` counts them all; page with `limit`, and `offset` = the tasks you left unlinked so far." )] async fn module_backfill_candidates( &self,