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..6953b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,65 @@ 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` (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. + +### Changed +- `migrate-project` re-keys the module tables too. +- 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. +- 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, `task_create` replies may carry + `chronicle` and `suggested_modules`, and `task-journal state` gains + `archive` and `active.modules`. + ## [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..101c407 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. 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. @@ -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-cli/src/main.rs b/crates/tj-cli/src/main.rs index ec6b28e..2abf7b3 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -122,6 +122,180 @@ fn dir_writable(dir: &std::path::Path) -> bool { r } +/// 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(&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")); + 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)?; + } + 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(|chr, conn, hash| { + Ok(( + tj_core::modules::map(chr)?, + tj_core::archive::gaps(chr, 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(|chr, _, _| tj_core::modules::page(chr, &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)?; + + // 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"); + } + 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, chr.home_events())) + })?; + append_to(&home_events, &[by_user(event)])?; + println!( + "{} {module_id}", + if created { "created" } else { "updated" } + ); + } + ModuleCmd::Link { + task_id, + add, + remove, + } => { + let event = with_chronicle(|chr, conn, _| { + 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)?; + } + + 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) + })?; + append_to_journal(&[by_user(event)])?; + println!("linked {task_id}"); + } + ModuleCmd::Candidates { 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)?); + } + } + + 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. @@ -292,8 +466,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"), @@ -302,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() { @@ -821,6 +1032,9 @@ enum Commands { /// Parent task id — makes this a subtask of the given id. #[arg(long)] parent: Option<String>, + /// Modules the task belongs to, comma-separated (see `module list`). + #[arg(long, value_delimiter = ',')] + modules: Vec<String>, }, /// List tasks for the current project. List { @@ -972,6 +1186,10 @@ enum Commands { /// primary field; the tag is for filtering / aggregation. #[arg(long)] outcome_tag: Option<String>, + /// A line of a module's history: `module=what this task changed there`. + /// Repeat for several modules. + #[arg(long = "module-note")] + module_notes: Vec<String>, }, /// Attach a clickable, typed link to a task (doc, deploy, dashboard, /// design, …). Renders under the pack's Artifacts as `[label](url)` so a @@ -1036,6 +1254,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, @@ -1347,6 +1571,54 @@ 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<String>, + #[arg(long)] + description: Option<String>, + /// A code path prefix that marks the module (repeatable). + #[arg(long = "path")] + paths: Vec<String>, + /// A term of the module (repeatable). + #[arg(long = "term")] + terms: Vec<String>, + /// How the module works now. + #[arg(long)] + state: Option<String>, + /// `active`, `retired` or `merged` (with `--merged-into`). + #[arg(long)] + status: Option<String>, + #[arg(long)] + merged_into: Option<String>, + }, + /// Link a task to modules, or unlink it. + Link { + task_id: String, + #[arg(long = "add")] + add: Vec<String>, + #[arg(long = "remove")] + remove: Vec<String>, + }, + /// Tasks that belong to no module, with suggestions (JSON). + Candidates { + #[arg(long, default_value_t = 20)] + limit: usize, + #[arg(long, default_value_t = 0)] + offset: usize, + }, +} + #[derive(Subcommand)] enum PendingCmd { /// List queued classifier failures. @@ -1400,6 +1672,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)?; @@ -1441,6 +1714,15 @@ fn real_main() -> Result<()> { if let Some(g) = goal { meta["goal"] = serde_json::Value::String(g); } + if !modules.is_empty() { + 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; let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; @@ -1482,6 +1764,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 @@ -1777,6 +2060,7 @@ fn real_main() -> Result<()> { &project_hash, session.as_deref(), prefer.as_deref(), + tj_core::chronicle::Chronicle::open(&cwd).ok().as_ref(), )? } else { tj_core::session_state::SessionState { @@ -1784,6 +2068,7 @@ fn real_main() -> Result<()> { session_id: session, open_tasks: 0, active: None, + archive: Vec::new(), } }; @@ -1821,6 +2106,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)?; @@ -1838,6 +2124,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. @@ -1849,6 +2142,24 @@ 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. + let chr = if notes.is_empty() { + None + } else { + Some(tj_core::chronicle::Chronicle::open(&cwd)?) + }; + for (module, _) in notes.iter_mut() { + 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}"); + } + *module = resolved.ids[0].clone(); + } drop(conn); let mut event = tj_core::event::Event::new( @@ -1871,6 +2182,17 @@ 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)); + 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 // what shipped. Best-effort; structured artifacts are merged in @@ -2050,6 +2372,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)?; @@ -2715,6 +3038,17 @@ 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::chronicle::Chronicle::open(&cwd) + .and_then(|chr| tj_core::archive::gaps(&chr, &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, @@ -3340,7 +3674,10 @@ 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: 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 { @@ -6544,6 +6881,9 @@ fn events_by_task( continue; } if let Ok(e) = serde_json::from_str::<Event>(line) { + if e.is_module() { + continue; + } by_task.entry(e.task_id.clone()).or_default().push(e); } } @@ -6820,6 +7160,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<_>>(), 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..20c983e 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"), @@ -1010,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(); @@ -8489,3 +8543,333 @@ 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 and every export format skip it. +#[test] +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 = || { + 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()); + } + // 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").not()); +} + +/// 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""#)); +} + +/// 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_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(); + + // 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 new file mode 100644 index 0000000..6664700 --- /dev/null +++ b/crates/tj-core/src/archive.rs @@ -0,0 +1,269 @@ +//! 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, schemars::JsonSchema)] +#[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 (leftovers go to a catch-all module)" + ), + 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<String> { + 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 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 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, + conn: &Connection, + project_hash: &str, + active_task: Option<&str>, +) -> anyhow::Result<Vec<Gap>> { + let count = |sql: &str| -> anyhow::Result<i64> { + 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()); + } + let modules: Vec<crate::modules::Module> = crate::modules::list(&chr.home, &chr.home_hash)?; + if modules.is_empty() { + 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(); + + 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 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 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 + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + 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 { + 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(&chronicle_of(&_d), &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(&chronicle_of(&_d), &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(&chronicle_of(&_d), &conn, "p", Some("tj-b")).unwrap(); + + assert_eq!( + g, + // What the agent can act on now comes first; sorting old + // tasks waits behind it. + vec![ + Gap::TaskWithoutModule { + task_id: "tj-b".into() + }, + Gap::StaleModule { + module_id: "stars".into(), + closed_since: 1 + }, + Gap::UnlinkedTasks { count: 1 }, + ] + ); + } + + #[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(&chronicle_of(&_d), &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(&chronicle_of(&_d), &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/chronicle.rs b/crates/tj-core/src/chronicle.rs new file mode 100644 index 0000000..f86a5dd --- /dev/null +++ b/crates/tj-core/src/chronicle.rs @@ -0,0 +1,470 @@ +//! 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 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`: its home's state, caught up. + 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); + let home = open_journal(data, &home_hash)?; + let members = member_hashes(data, &home_hash)? + .into_iter() + .filter(|h| *h != home_hash) + .collect(); + + Ok(Self { + home_hash, + home, + member_hash, + members, + 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, + member_hash: None, + members: Vec::new(), + data: PathBuf::new(), + } + } + + 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. + 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, and list + /// the member under its home: from now on its tasks join the history. + pub fn stamp(&self, meta: &mut serde_json::Value) { + let Some(member) = &self.member_hash else { + return; + }; + + 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:#}" + ); + } + } +} + +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"); + 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()); + } + } + } + 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.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] + 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/db.rs b/crates/tj-core/src/db.rs index 04de66a..88f008a 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,11 @@ 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(()); + } + match event.event_type { EventType::Open => { let title = event @@ -900,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, @@ -927,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) @@ -956,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( @@ -1000,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, @@ -1009,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() { @@ -1075,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); @@ -1108,6 +1173,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 +1733,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:<id>`): 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:<id>`): 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<String>, 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!( diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index e66599a..4b4155f 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -57,7 +57,9 @@ mod task_id_tests { } } +pub mod archive; pub mod artifacts; +pub mod chronicle; pub mod classifier; pub mod completeness; pub mod consolidate; @@ -71,6 +73,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..38b0226 --- /dev/null +++ b/crates/tj-core/src/modules.rs @@ -0,0 +1,1640 @@ +//! 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:<id>`, 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<String>, + #[serde(default)] + pub terms: Vec<String>, +} + +/// A partial update of a module: `None` leaves the field as it is. +#[derive(Debug, Clone, Default)] +pub struct ModuleFields { + pub name: Option<String>, + pub description: Option<String>, + pub hints: Option<Hints>, + pub state: Option<String>, + pub status: Option<String>, + pub merged_into: Option<String>, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct Module { + pub module_id: String, + pub name: String, + pub description: Option<String>, + pub hints: Hints, + /// How the module works now: a short living text the AI rewrites. + pub state: Option<String>, + pub state_at: Option<String>, + pub status: String, + pub merged_into: Option<String>, + pub task_count: i64, + pub last_activity: Option<String>, +} + +/// Module ids ready to link, with what was redirected on the way. +#[derive(Debug, Default)] +pub struct Resolved { + pub ids: Vec<String>, + pub warnings: Vec<String>, +} + +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<Event> { + 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<String> { + 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], + )?; + } + + 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<Module> { + 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<Option<Module>> { + 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<Vec<Module>> { + 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::<Result<_, _>>()?; + + 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)?; + + 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 + 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(()) + }); + + 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( + "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::<Result<_, _>>()?; + + 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<Resolved> { + 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) +} + +/// 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, JsonSchema)] +pub struct Suggestion { + pub module_id: String, + pub name: String, + pub score: u32, + /// What matched: files, terms, words. + 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. +/// 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 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(); + + !before.is_some_and(char::is_alphanumeric) && !after.is_some_and(char::is_alphanumeric) + }) +} + +fn task_titles( + conn: &Connection, + project_hash: &str, + module_id: &str, +) -> anyhow::Result<Vec<String>> { + 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::<Result<_, _>>()?; + + 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<Vec<Suggestion>> { + let words = stems(text); + let lower = text.to_lowercase().replace('ё', "е"); + 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 && has_phrase(&lower, &t.to_lowercase().replace('ё', "е")) + }) + .collect(); + if !terms.is_empty() { + score += 2 * terms.len() as u32; + why.push(format!("terms: {}", terms.join(", "))); + } + + // 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(" ") + )); + 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) + .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() { + 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, JsonSchema)] +pub struct Candidate { + pub task_id: String, + pub title: String, + pub status: String, + pub goal: Option<String>, + pub outcome: Option<String>, + pub files: Vec<String>, + pub suggestions: Vec<Suggestion>, +} + +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 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, + offset: usize, +) -> anyhow::Result<(i64, Vec<Candidate>)> { + 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, t.task_id LIMIT ?2 OFFSET ?3" + ))?; + let rows: Vec<(String, String, String)> = stmt + .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(); + 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(&chr.home, &chr.home_hash, &text, &files)?; + + out.push(Candidate { + task_id, + title, + status, + goal: meta.goal, + outcome: meta.outcome, + files, + suggestions, + }); + } + + Ok((total, out)) +} + +/// A module page is read like a full task pack: same budget. +const PAGE_BUDGET: usize = 32 * 1024; + +/// 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; + +/// 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<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, 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.module_id = ?1", + )?; + + let mut rows = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for id in ids { + let part = stmt.query_map([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)?, + decisions: Vec::new(), + rejected: Vec::new(), + constraints: Vec::new(), + }) + })?; + for row in part { + let row = row?; + if seen.insert(row.task_id.clone()) { + rows.push(row); + } + } + } + + 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 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", + )?; + // 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")?; + + 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 { + 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}…") +} + +/// `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(limit); + 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(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"); + }; + + // 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?); + } + // 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)); + 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 { + 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); + push_section(&mut out, "Constraints", &constraints, OTHER_ITEMS); + + let open: Vec<String> = open + .iter() + .map(|r| format!("- {} {}", r.task_id, one_line(&r.title, ENTRY_CHARS))) + .collect(); + push_section(&mut out, "Open tasks", &open, OTHER_ITEMS); + + 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!( + "- {} · {} · {} — {}\n", + day(&r.at), + r.task_id, + one_line(&r.title, ENTRY_CHARS), + one_line(outcome, ENTRY_CHARS) + )); + if let Some(note) = &r.note { + out.push_str(&format!(" ↳ {}\n", one_line(note, ENTRY_CHARS))); + } + } else { + out.push_str(&format!( + "- {} · {} · {}\n", + day(&r.at), + r.task_id, + one_line(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; + 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) + } + + /// 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}); + + 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::{chronicle_of, 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}"); + } + + 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()); + } + + 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 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"), + ]); + + 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( + "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(&chronicle_of(&_d), &conn, "p", 10, 0).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, 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)); + } + + 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(&chronicle_of(&_d), "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(&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(&[ + named("stars", "Stars"), + open_task("tj-a", &["stars"]), + close_task("tj-a", serde_json::json!({})), + ]); + let p = page(&chronicle_of(&_d), "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(&chronicle_of(&_d), "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(&chronicle_of(&_d), "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 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(&chronicle_of(&_d), "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(); + + let err = page(&chronicle_of(&_d), "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 + // 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()); + } +} diff --git a/crates/tj-core/src/pack.rs b/crates/tj-core/src/pack.rs index 4759c76..b694a58 100644 --- a/crates/tj-core/src/pack.rs +++ b/crates/tj-core/src/pack.rs @@ -428,6 +428,23 @@ 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<String> = modules + .iter() + // 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(", "))); + } + // 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 +1239,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; diff --git a/crates/tj-core/src/project_hash.rs b/crates/tj-core/src/project_hash.rs index 80aae36..2746623 100644 --- a/crates/tj-core/src/project_hash.rs +++ b/crates/tj-core/src/project_hash.rs @@ -34,6 +34,46 @@ pub fn project_root(start: &Path) -> PathBuf { } } +/// 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`, 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); + // 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()?; + 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, + } +} + 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 @@ -137,6 +177,123 @@ 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_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); + } + + #[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, + // 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/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<String> { + 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<String> { - let tokens: Vec<String> = 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; } diff --git a/crates/tj-core/src/session_state.rs b/crates/tj-core/src/session_state.rs index d3c4b6c..2be7c54 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<String>, pub open_tasks: i64, pub active: Option<ActiveTask>, + /// What the project chronicle is missing, most important first. + pub archive: Vec<crate::archive::Gap>, } #[derive(Debug, Serialize)] @@ -35,6 +37,8 @@ pub struct ActiveTask { pub counts: BTreeMap<String, i64>, /// The latest entries, oldest first. No `open` / `amend` bookkeeping. pub recent: Vec<RecentEntry>, + /// Names of the modules the task belongs to. + pub modules: Vec<String>, } #[derive(Debug, Serialize)] @@ -54,6 +58,7 @@ pub fn session_state( project_hash: &str, session_id: Option<&str>, prefer: Option<&str>, + 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'", @@ -76,17 +81,36 @@ 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()?; + // 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, session_id: session_id.map(str::to_string), open_tasks, active, + archive, }) } -fn active_task(conn: &Connection, task_id: &str) -> anyhow::Result<ActiveTask> { +fn active_task( + conn: &Connection, + task_id: &str, + chr: Option<&crate::chronicle::Chronicle>, +) -> anyhow::Result<ActiveTask> { let (title, goal) = conn.query_row( "SELECT title, goal FROM tasks WHERE task_id = ?1", [task_id], @@ -131,6 +155,18 @@ fn active_task(conn: &Connection, task_id: &str) -> anyhow::Result<ActiveTask> { goal: goal.filter(|g| !g.trim().is_empty()), counts, recent, + modules: crate::modules::modules_of_task(conn, task_id)? + .into_iter() + .map(|(id, name)| { + // A worktree's own state has no modules: names live in the home. + chr.and_then(|chr| { + crate::modules::get(&chr.home, &chr.home_hash, &id) + .ok() + .flatten() + }) + .map_or(name, |m| m.name) + }) + .collect(), }) } @@ -151,6 +187,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(); @@ -158,6 +200,46 @@ 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, Some(&chronicle(&_d))).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 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(); @@ -165,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).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"); @@ -175,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).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); } @@ -187,16 +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).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")).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")).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")).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"); } @@ -225,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) + let active = session_state(&conn, "p", Some("s1"), None, Some(&chronicle(&_dir))) .unwrap() .active .unwrap(); @@ -244,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).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); } @@ -254,13 +357,19 @@ 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, Some(&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, 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 f5bbb61..a05c145 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<String>, + /// Only tasks linked to this module. + pub module: Option<String>, } #[derive(Debug, Serialize, schemars::JsonSchema)] pub struct TaskSearchResult { @@ -241,11 +245,32 @@ pub struct TaskCreateParams { pub parent: Option<String>, /// Filled in by the client or the Task Journal Claude Code mod; leave it out. pub session_id: Option<String>, + /// 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<Vec<String>>, } -#[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<ModuleBrief>, + /// 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<tj_core::modules::Suggestion>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec<String>, + /// The chronicle's most important gap, to close once the current work is done. + #[serde(skip_serializing_if = "Option::is_none")] + pub chronicle: Option<String>, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModuleBrief { + pub module_id: String, + pub name: String, + pub state: Option<String>, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -298,6 +323,14 @@ pub struct TaskCloseParams { pub outcome_tag: Option<String>, /// Filled in by the client or the Task Journal Claude Code mod; leave it out. pub session_id: Option<String>, + /// One line per module on what this task changed there: the module's history. + pub module_notes: Option<Vec<ModuleNote>>, +} + +#[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<String>, + /// 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<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec<String>, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -329,6 +367,81 @@ 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<String>, + /// One to three sentences: what this part of the system is. + pub description: Option<String>, + /// Code path prefixes and terms that mark the module; they drive suggestions. + pub hints: Option<tj_core::modules::Hints>, + /// How the module works now: a short living text you rewrite when it changes. + pub state: Option<String>, + /// `active`, `retired` or `merged` (with `merged_into`). + pub status: Option<String>, + pub merged_into: Option<String>, +} +#[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<String>, + #[serde(default)] + pub remove: Vec<String>, +} +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ModuleLinkParams { + pub links: Vec<ModuleLink>, +} +#[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<String>, +} + +#[derive(Debug, Serialize, schemars::JsonSchema)] +pub struct ModuleListResult { + pub modules: Vec<tj_core::modules::Module>, + /// What the chronicle is missing, most important first. + pub gaps: Vec<tj_core::archive::Gap>, + #[serde(skip_serializing_if = "Option::is_none")] + pub chronicle: Option<String>, +} + +#[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<usize>, + /// 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)] +pub struct BackfillResult { + pub total_unlinked: i64, + pub candidates: Vec<tj_core::modules::Candidate>, +} + fn parse_event_type(s: &str) -> anyhow::Result<tj_core::event::EventType> { use tj_core::event::EventType::*; Ok(match s { @@ -400,6 +513,139 @@ fn require_task( Ok(()) } +/// Run `f` on this project's state with the journal tail ingested first. +fn with_state<T>(f: impl FnOnce(&Connection, &str) -> anyhow::Result<T>) -> anyhow::Result<T> { + 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(()) +} + +/// 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> { + 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 +/// 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<ModuleBrief>, + Vec<tj_core::modules::Suggestion>, + Option<String>, +)> { + with_chronicle(|chr, conn, project_hash| { + let mut modules = Vec::new(); + for id in linked { + 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, + state: m.state, + }); + } + } + let suggested = if linked.is_empty() { + tj_core::modules::suggest(&chr.home, &chr.home_hash, text, &[])? + } else { + Vec::new() + }; + let gaps = tj_core::archive::gaps(chr, 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( + chr: &tj_core::chronicle::Chronicle, + conn: &Connection, + 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(&chr.home, &chr.home_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::<Vec<_>>() + .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 — 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=...)." + ), + })) +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct TaskCheckParams { pub task_id: String, @@ -522,7 +768,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 +778,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 +814,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::<Result<_, _>>()?; return Ok(hits); @@ -587,11 +839,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<TaskSearchHit> = stmt .query_map( - rusqlite::params![fts_query, event_type, status], + rusqlite::params![fts_query, event_type, status, module], task_search_hit, )? .collect::<Result<_, _>>()?; @@ -608,10 +862,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::<Result<_, _>>()?; } @@ -631,7 +890,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 +922,7 @@ impl TaskJournalServer { return Ok(TaskCreateResult { task_id: existing, title: p.title.clone(), + ..Default::default() }); } } @@ -684,6 +944,17 @@ 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() => { + let chr = chronicle()?; + let r = tj_core::modules::resolve(&chr.home, &chr.home_hash, ids)?; + Some((r, chr)) + } + _ => None, + }; + let mut event = tj_core::event::Event::new( task_id.clone(), tj_core::event::EventType::Open, @@ -704,6 +975,10 @@ impl TaskJournalServer { if let Some(ref r) = loom_ref { event.meta["external"] = serde_json::json!([r]); } + 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, session_id_or_env(p.session_id.as_deref()).as_deref(), @@ -713,9 +988,27 @@ 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 +1121,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 +1129,18 @@ impl TaskJournalServer { ) -> Result<Json<TaskCloseResult>, 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(); + // 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() @@ -865,6 +1165,18 @@ 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 chr = chr.as_ref().expect("opened when notes are given"); + let r = tj_core::modules::resolve( + &chr.home, + &chr.home_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 +1194,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 @@ -895,6 +1213,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(), @@ -909,6 +1230,7 @@ impl TaskJournalServer { // assess. Any error here must NOT fail the close — handle // locally, never `?`-propagate. let mut gaps: Vec<String> = 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 +1240,14 @@ impl TaskJournalServer { ) { gaps = report.gaps.into_iter().map(|g| g.detail).collect(); } + module_reminder = chronicle() + .and_then(|chr| { + crate::module_reminder(&chr, &conn, &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 +1260,207 @@ 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<Json<ModuleListResult>, 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_chronicle(|chr, conn, project_hash| { + let gaps = tj_core::archive::gaps(chr, conn, project_hash, None)?; + + Ok(Json(ModuleListResult { + modules: tj_core::modules::map(chr)?, + 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<ModulePageParams>, + ) -> Result<Json<ModulePageResult>, 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 = tj_core::modules::page(&chronicle()?, &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<ModuleSaveParams>, + ) -> Result<Json<ModuleSaveResult>, 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)?; + + // 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); + } + 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"); + } + } + + !exists + }; + append_to(&chr.home_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<ModuleLinkParams>, + ) -> Result<Json<ModuleLinkResult>, McpError> { + traced_tool("module_link", async move { + run_blocking(move || { + let mut warnings = Vec::new(); + 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)? { + 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(&chr.home, &chr.home_hash, &link.add)?; + warnings.extend(add.warnings); + if add.ids.is_empty() && link.remove.is_empty() { + continue; + } + 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) + })?; + 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`, and `offset` = the tasks you left unlinked so far." + )] + async fn module_backfill_candidates( + &self, + Parameters(p): Parameters<BackfillParams>, + ) -> Result<Json<BackfillResult>, 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_chronicle(|chr, conn, project_hash| { + tj_core::modules::backfill_candidates( + chr, + conn, + project_hash, + limit, + p.offset.unwrap_or(0), + ) + })?; + + 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 +1724,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 +1744,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 +1903,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1415,6 +1950,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1459,6 +1995,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1472,6 +2009,7 @@ mod tests { goal: None, parent: Some(parent.clone()), session_id: None, + modules: None, })) .await .unwrap() @@ -1503,6 +2041,7 @@ mod tests { goal: None, parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1517,6 +2056,7 @@ mod tests { goal: None, parent: Some(parent.clone()), session_id: None, + modules: None, })) .await .unwrap(); @@ -1528,6 +2068,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap() @@ -1546,6 +2087,7 @@ mod tests { goal: Some("g".into()), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1584,6 +2126,7 @@ mod tests { goal: Some("ship it".into()), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1598,6 +2141,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap() @@ -1625,6 +2169,7 @@ mod tests { goal: Some("ship it".into()), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1671,6 +2216,7 @@ mod tests { goal: Some(format!("goal of {title}")), parent: None, session_id: None, + modules: None, })) .await .unwrap() @@ -1691,6 +2237,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 +2264,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap(); @@ -1767,6 +2315,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap(); @@ -1857,6 +2406,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: None, + module_notes: None, })) .await .unwrap(); @@ -1887,6 +2437,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 +2536,7 @@ mod tests { goal: None, parent: None, session_id: Some("s-create".into()), + modules: None, })) .await .unwrap() @@ -2017,6 +2569,7 @@ mod tests { outcome: None, outcome_tag: None, session_id: Some("s-close".into()), + module_notes: None, })) .await; match prev { @@ -2057,6 +2610,309 @@ 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<Vec<(&str, &str)>>) -> 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_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 + .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), + offset: None, + })) + .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 +2946,7 @@ mod tests { goal: Some("Wire the board".into()), parent: None, session_id: None, + modules: None, })) .await; std::env::remove_var("LOOM_TASK_ID"); diff --git a/crates/tj-mcp/tests/skill_doc.rs b/crates/tj-mcp/tests/skill_doc.rs index f45c5fd..27d3920 100644 --- a/crates/tj-mcp/tests/skill_doc.rs +++ b/crates/tj-mcp/tests/skill_doc.rs @@ -65,3 +65,49 @@ 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", + "catch-all", + "offset", + ] { + 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", + "catch-all", + ] { + 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/.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/commands/map.md b/plugin/commands/map.md new file mode 100644 index 0000000..1d2d023 --- /dev/null +++ b/plugin/commands/map.md @@ -0,0 +1,27 @@ +--- +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 (`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. 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. + +Finish with `module_list` and tell the user what the map holds and what is left. diff --git a/plugin/hooks/journal.test.ts b/plugin/hooks/journal.test.ts index 1c269b6..4f7f389 100644 --- a/plugin/hooks/journal.test.ts +++ b/plugin/hooks/journal.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'claude-code/testing' import { + chronicleKeys, + chronicleNudge, journalTool, nudgeText, parseDistill, @@ -8,6 +10,8 @@ import { sectionText, statusText, transcriptExcerpt, + REFRESH_TOOLS, + WRITE_TOOLS, type JournalState, } from './journal' @@ -33,7 +37,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 +54,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 +76,91 @@ 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('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) + }) +}) + +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 64f7b9f..4677cae 100644 --- a/plugin/hooks/journal.ts +++ b/plugin/hooks/journal.ts @@ -9,11 +9,18 @@ export type ActiveTask = { goal: string | null counts: Record<string, number> 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' @@ -30,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] } @@ -48,9 +59,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<string, unknown> | 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 +83,10 @@ export function parseState(stdout: string): JournalState | null { goal: typeof a.goal === 'string' && a.goal.trim() !== '' ? a.goal : null, counts: (a.counts as Record<string, number>) ?? {}, recent, + modules: Array.isArray(a.modules) ? a.modules.filter((m): m is string => typeof m === 'string') : [], }, open_tasks: open, + archive, } } @@ -79,8 +97,51 @@ 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, (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 (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)`, +} - return `📓 ${a.task_id} · ${n('decision')} decisions · ${n('rejection')} rejected · ${n('evidence')} evidence` +// 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<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, 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, + 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 + + 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 3d8e700..97025bf 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,12 +19,15 @@ import type { EngineInterface, Register } from 'claude-code' import { + chronicleKeys, + chronicleNudge, compactInstruction, distillPrompt, journalTool, nudgeText, parseDistill, parseState, + REFRESH_TOOLS, sectionText, statusText, transcriptExcerpt, @@ -62,6 +67,9 @@ const mod = { pinned: null as string | null, turnsSinceEntry: 0, workSinceEntry: 0, + // 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 = @@ -122,7 +130,10 @@ async function refresh($: EngineInterface): Promise<void> { return } - if (mod.session !== session) mod.pinned = null + if (mod.session !== session) { + mod.pinned = null + mod.chronicleSeen = new Set(chronicleKeys(state)) + } mod.session = session mod.state = state mod.pinned = state.active?.task_id ?? null @@ -219,6 +230,7 @@ export const register: Register = (on, options) => { mod.retryAt = 0 mod.turnsSinceEntry = 0 mod.workSinceEntry = 0 + mod.chronicleSeen = new Set() return next(e) }) @@ -244,14 +256,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, mod.chronicleSeen) + if (chronicle !== null) { + mod.chronicleSeen.add(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) => { @@ -267,7 +290,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. 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", diff --git a/plugin/skills/task-journal/SKILL.md b/plugin/skills/task-journal/SKILL.md index 9087e9a..d05bc22 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 <module>?". --- # 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=<short>, goal=<one sentence: what the user is trying to accomplish>)`. **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,60 @@ 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. 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`. + 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 (`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: + +- `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?, offset?)` → `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