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 [--goal "..."] [--modules a,b]` | Open a task with optional goal and modules |
+| `module list [--json] \| show \| save [--name] [--description] [--path P] [--term T] [--state] [--status] [--merged-into] \| link [--add M] [--remove M] \| candidates [--limit N]` | The project chronicle: module map, module pages, creating and linking modules, tasks without a module |
| `goal "..."` | Set or replace a task's goal |
| `event --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 --task --text "..."` | Correct an earlier event |
| `external "..."` | Append an external reference (URL, ticket, linked task) |
-| `close --outcome "..." --outcome-tag done\|abandoned\|superseded` | Close with outcome |
+| `close --outcome "..." --outcome-tag done\|abandoned\|superseded [--module-note module=text]` | Close with outcome (and a line of each module's history) |
| `reopen --reason "..."` | Reopen a closed task |
| `pack --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(
+ f: impl FnOnce(&tj_core::chronicle::Chronicle, &rusqlite::Connection, &str) -> Result,
+) -> Result {
+ 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,
+ /// Modules the task belongs to, comma-separated (see `module list`).
+ #[arg(long, value_delimiter = ',')]
+ modules: Vec,
},
/// 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,
+ /// A line of a module's history: `module=what this task changed there`.
+ /// Repeat for several modules.
+ #[arg(long = "module-note")]
+ module_notes: Vec,
},
/// Attach a clickable, typed link to a task (doc, deploy, dashboard,
/// design, …). Renders under the pack's Artifacts as `[label](url)` so a
@@ -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,
+ #[arg(long)]
+ description: Option,
+ /// A code path prefix that marks the module (repeatable).
+ #[arg(long = "path")]
+ paths: Vec,
+ /// A term of the module (repeatable).
+ #[arg(long = "term")]
+ terms: Vec,
+ /// How the module works now.
+ #[arg(long)]
+ state: Option,
+ /// `active`, `retired` or `merged` (with `--merged-into`).
+ #[arg(long)]
+ status: Option,
+ #[arg(long)]
+ merged_into: Option,
+ },
+ /// Link a task to modules, or unlink it.
+ Link {
+ task_id: String,
+ #[arg(long = "add")]
+ add: Vec,
+ #[arg(long = "remove")]
+ remove: Vec,
+ },
+ /// Tasks that belong to no module, with suggestions (JSON).
+ Candidates {
+ #[arg(long, default_value_t = 20)]
+ limit: usize,
+ #[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::(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!["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 {
+ 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> {
+ let count = |sql: &str| -> anyhow::Result {
+ Ok(conn.query_row(sql, [project_hash], |r| r.get(0))?)
+ };
+
+ let tasks = count("SELECT COUNT(*) FROM tasks WHERE project_hash = ?1")?;
+ if tasks == 0 {
+ return Ok(Vec::new());
+ }
+ let modules: Vec = 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,
+ /// 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,
+ 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::open_in(&crate::paths::data_dir()?, dir)
+ }
+
+ /// [`Chronicle::open`] with the data dir given.
+ pub fn open_in(data: &Path, dir: &Path) -> anyhow::Result {
+ 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 {
+ 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> {
+ 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::(),
+ )?;
+ return Ok(found);
+ }
+ Err(e) => return Err(e.into()),
+ };
+
+ let mut out: Vec = 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> {
+ 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::(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:`): a partial
+ /// update of the module's fields in `meta`. Not a task's reasoning — kept
+ /// out of tasks, packs, search and recall. Written by `module_save`.
+ Module,
}
impl EventType {
@@ -40,6 +44,7 @@ impl EventType {
Self::Redirect,
Self::Rename,
Self::Amend,
+ Self::Module,
];
}
@@ -114,6 +119,11 @@ pub struct Event {
}
impl Event {
+ /// A module's own event (`mod:`): project metadata, not a task's reasoning.
+ pub fn is_module(&self) -> bool {
+ self.event_type == EventType::Module
+ }
+
pub fn new(
task_id: impl Into,
event_type: EventType,
@@ -161,6 +171,25 @@ mod tests {
}
}
+ #[test]
+ fn module_event_round_trips_and_is_recognised() {
+ let mut e = Event::new(
+ "mod:stars",
+ EventType::Module,
+ Author::Agent,
+ Source::Chat,
+ "Stars".into(),
+ );
+ e.meta = serde_json::json!({"module_id": "stars", "name": "Stars"});
+
+ let line = serde_json::to_string(&e).unwrap();
+ assert!(line.contains(r#""type":"module""#), "{line}");
+
+ let back: Event = serde_json::from_str(&line).unwrap();
+ assert!(back.is_module());
+ assert!(EventType::ALL.contains(&EventType::Module));
+ }
+
#[test]
fn author_source_status_strength_serialize_snake_case() {
assert_eq!(
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:`, partial updates, the
+//! last value of a field wins); links ride in `open` / `amend` / `close`
+//! meta, which 0.30 already reads and ignores.
+
+use rusqlite::{Connection, OptionalExtension};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::event::{Author, Event, EventType, Source};
+
+/// `task_id` prefix of a module's events; never collides with `tj-…`.
+pub const ID_PREFIX: &str = "mod:";
+
+const MAX_ID: usize = 48;
+
+/// What marks a module in a task: code path prefixes and the module's terms.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
+pub struct Hints {
+ #[serde(default)]
+ pub paths: Vec,
+ #[serde(default)]
+ pub terms: Vec,
+}
+
+/// A partial update of a module: `None` leaves the field as it is.
+#[derive(Debug, Clone, Default)]
+pub struct ModuleFields {
+ pub name: Option,
+ pub description: Option,
+ pub hints: Option,
+ pub state: Option,
+ pub status: Option,
+ pub merged_into: Option,
+}
+
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct Module {
+ pub module_id: String,
+ pub name: String,
+ pub description: Option,
+ pub hints: Hints,
+ /// How the module works now: a short living text the AI rewrites.
+ pub state: Option,
+ pub state_at: Option,
+ pub status: String,
+ pub merged_into: Option,
+ pub task_count: i64,
+ pub last_activity: Option,
+}
+
+/// Module ids ready to link, with what was redirected on the way.
+#[derive(Debug, Default)]
+pub struct Resolved {
+ pub ids: Vec,
+ pub warnings: Vec,
+}
+
+pub fn validate_id(id: &str) -> anyhow::Result<()> {
+ let mut chars = id.chars();
+ let is_first_ok = chars
+ .next()
+ .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
+ let is_rest_ok = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
+ if !is_first_ok || !is_rest_ok || id.len() > MAX_ID {
+ anyhow::bail!(
+ "invalid module id {id:?}: use lowercase latin letters, digits and dashes, \
+ starting with a letter or digit, at most {MAX_ID} chars (e.g. `stars`, `auth-refresh`)"
+ );
+ }
+
+ Ok(())
+}
+
+/// The journal event that creates or updates `module_id` with the given fields.
+pub fn module_event(module_id: &str, f: &ModuleFields) -> anyhow::Result {
+ validate_id(module_id)?;
+ if let Some(status) = f.status.as_deref() {
+ if !["active", "retired", "merged"].contains(&status) {
+ anyhow::bail!("module status must be active, retired or merged, not {status:?}");
+ }
+ if status == "merged" && f.merged_into.is_none() {
+ anyhow::bail!("status merged needs merged_into: the module that took over");
+ }
+ }
+
+ let text = f
+ .name
+ .clone()
+ .or_else(|| f.description.clone())
+ .unwrap_or_else(|| format!("module {module_id} updated"));
+ let mut event = Event::new(
+ format!("{ID_PREFIX}{module_id}"),
+ EventType::Module,
+ Author::Agent,
+ Source::Chat,
+ text,
+ );
+
+ let mut meta = serde_json::json!({ "module_id": module_id });
+ let fields = [
+ ("name", &f.name),
+ ("description", &f.description),
+ ("state", &f.state),
+ ("status", &f.status),
+ ("merged_into", &f.merged_into),
+ ];
+ for (key, value) in fields {
+ if let Some(v) = value {
+ meta[key] = serde_json::Value::String(v.clone());
+ }
+ }
+ if let Some(h) = &f.hints {
+ meta["hints"] = serde_json::to_value(h)?;
+ }
+ event.meta = meta;
+
+ Ok(event)
+}
+
+/// The `amend` that links `task_id` to `add` and unlinks it from `remove`.
+pub fn link_event(task_id: &str, add: &[String], remove: &[String]) -> Event {
+ let mut event = Event::new(
+ task_id,
+ EventType::Amend,
+ Author::Agent,
+ Source::Chat,
+ "modules changed".to_string(),
+ );
+ event.meta = serde_json::json!({ "modules_add": add, "modules_remove": remove });
+
+ event
+}
+
+/// The value of `close.meta.module_notes`: one history line per module.
+pub fn notes_meta(notes: &[(String, String)]) -> serde_json::Value {
+ notes
+ .iter()
+ .map(|(module, text)| serde_json::json!({ "module": module, "text": text }))
+ .collect()
+}
+
+/// Apply what `event` says about modules to the derived state. Called for
+/// every event before its task projection.
+pub fn project(conn: &Connection, event: &Event, project_hash: &str) -> anyhow::Result<()> {
+ match event.event_type {
+ EventType::Module => apply_module(conn, event, project_hash),
+ EventType::Open => link(
+ conn,
+ project_hash,
+ &event.task_id,
+ &strings(&event.meta, "modules"),
+ &[],
+ ),
+ EventType::Amend => link(
+ conn,
+ project_hash,
+ &event.task_id,
+ &strings(&event.meta, "modules_add"),
+ &strings(&event.meta, "modules_remove"),
+ ),
+ EventType::Close => add_notes(conn, project_hash, event),
+ _ => Ok(()),
+ }
+}
+
+fn strings(meta: &serde_json::Value, key: &str) -> Vec {
+ meta.get(key)
+ .and_then(|v| v.as_array())
+ .map(|a| {
+ a.iter()
+ .filter_map(|v| v.as_str().map(str::to_string))
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+fn apply_module(conn: &Connection, event: &Event, project_hash: &str) -> anyhow::Result<()> {
+ let Some(id) = event.task_id.strip_prefix(ID_PREFIX) else {
+ return Ok(());
+ };
+ if validate_id(id).is_err() {
+ tracing::warn!(
+ task_id = event.task_id.as_str(),
+ "skipping a module event with an invalid id"
+ );
+ return Ok(());
+ }
+
+ let field = |k: &str| {
+ event
+ .meta
+ .get(k)
+ .and_then(|v| v.as_str())
+ .map(str::to_string)
+ };
+ let hints = event.meta.get("hints").map(|v| v.to_string());
+ conn.execute(
+ "INSERT INTO modules(project_hash, module_id, name, description, hints, state, state_at,
+ status, merged_into, created_at, updated_at)
+ VALUES (?1, ?2, COALESCE(?3, ?2), ?4, COALESCE(?5, '{}'), ?6,
+ CASE WHEN ?6 IS NULL THEN NULL ELSE ?9 END, COALESCE(?7, 'active'), ?8, ?9, ?9)
+ ON CONFLICT(project_hash, module_id) DO UPDATE SET
+ name = COALESCE(?3, name),
+ description = COALESCE(?4, description),
+ hints = COALESCE(?5, hints),
+ state = COALESCE(?6, state),
+ state_at = CASE WHEN ?6 IS NULL THEN state_at ELSE ?9 END,
+ status = COALESCE(?7, status),
+ merged_into = COALESCE(?8, merged_into),
+ updated_at = ?9",
+ rusqlite::params![
+ project_hash,
+ id,
+ field("name"),
+ field("description"),
+ hints,
+ field("state"),
+ field("status"),
+ field("merged_into"),
+ event.timestamp
+ ],
+ )?;
+
+ Ok(())
+}
+
+fn link(
+ conn: &Connection,
+ project_hash: &str,
+ task_id: &str,
+ add: &[String],
+ remove: &[String],
+) -> anyhow::Result<()> {
+ for m in add {
+ conn.execute(
+ "INSERT OR IGNORE INTO task_modules(project_hash, task_id, module_id) VALUES (?1, ?2, ?3)",
+ rusqlite::params![project_hash, task_id, m],
+ )?;
+ }
+ for m in remove {
+ conn.execute(
+ "DELETE FROM task_modules WHERE task_id = ?1 AND module_id = ?2",
+ rusqlite::params![task_id, m],
+ )?;
+ }
+
+ Ok(())
+}
+
+fn add_notes(conn: &Connection, project_hash: &str, event: &Event) -> anyhow::Result<()> {
+ let Some(notes) = event.meta.get("module_notes").and_then(|v| v.as_array()) else {
+ return Ok(());
+ };
+
+ for note in notes {
+ let module = note.get("module").and_then(|v| v.as_str());
+ let text = note.get("text").and_then(|v| v.as_str());
+ let (Some(module), Some(text)) = (module, text) else {
+ continue;
+ };
+
+ conn.execute(
+ "INSERT OR IGNORE INTO module_notes(project_hash, event_id, module_id, task_id, text, at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ rusqlite::params![
+ project_hash,
+ event.event_id,
+ module,
+ event.task_id,
+ text,
+ event.timestamp
+ ],
+ )?;
+ // A task that writes a module's history belongs to it.
+ link(
+ conn,
+ project_hash,
+ &event.task_id,
+ &[module.to_string()],
+ &[],
+ )?;
+ }
+
+ Ok(())
+}
+
+/// Drop this project's module rows before a replay re-creates them.
+pub fn clear(conn: &Connection, project_hash: &str) -> anyhow::Result<()> {
+ for table in ["modules", "task_modules", "module_notes"] {
+ conn.execute(
+ &format!("DELETE FROM {table} WHERE project_hash = ?1"),
+ [project_hash],
+ )?;
+ }
+
+ Ok(())
+}
+
+const MODULE_SELECT: &str = "SELECT m.module_id, m.name, m.description, m.hints, m.state,
+ m.state_at, m.status, m.merged_into,
+ (SELECT COUNT(*) FROM task_modules tm
+ WHERE tm.project_hash = m.project_hash AND tm.module_id = m.module_id),
+ (SELECT MAX(t.last_event_at) FROM task_modules tm JOIN tasks t ON t.task_id = tm.task_id
+ WHERE tm.project_hash = m.project_hash AND tm.module_id = m.module_id)
+ FROM modules m WHERE m.project_hash = ?1";
+
+fn module_row(r: &rusqlite::Row<'_>) -> rusqlite::Result {
+ let hints: String = r.get(3)?;
+
+ Ok(Module {
+ module_id: r.get(0)?,
+ name: r.get(1)?,
+ description: r.get(2)?,
+ hints: serde_json::from_str(&hints).unwrap_or_default(),
+ state: r.get(4)?,
+ state_at: r.get(5)?,
+ status: r.get(6)?,
+ merged_into: r.get(7)?,
+ task_count: r.get(8)?,
+ last_activity: r.get(9)?,
+ })
+}
+
+pub fn get(conn: &Connection, project_hash: &str, id: &str) -> anyhow::Result