diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ed0f93..d85bf14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.5] - 2026-06-17 + +### Added +- **Closed tasks become a clickable ledger.** When a task is closed, + `task_close` / `close` now harvests the real refs of what shipped — the + current commit, branch, and (when `gh` is available) the PR URL — straight + from `git`/`gh` and stamps them onto the close event as structured + artifacts. The resume pack renders them under **Artifacts** (`commits:`, + `branches:`, `PRs:`), so a month later the task shows *where it landed* + without relying on the agent having typed the refs into prose. Deterministic + and best-effort: no repo, no `gh`, or a detached HEAD just yields fewer + artifacts and never fails the close. Structured artifacts ride in + `event.meta["artifacts"]` and are merged with the existing text-scrape in + `db::index_event`. + ## [0.26.4] - 2026-06-17 ### Fixed @@ -17,8 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 failed every classification, silently piling chunks into the pending queue. The classifier now feeds the prompt on **stdin** (like the `complete`/enrich and dream backends already did), so chunks classify instead of dead-lettering. - Run `task-journal pending retry` once to drain a backlog accumulated by the - old behavior. + The backlog drains on its own as the capture hook re-spawns the classify + worker — no operator action needed. ## [0.26.3] - 2026-06-16 diff --git a/Cargo.lock b/Cargo.lock index 26c8a23..9dee049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.26.4" +version = "0.26.5" dependencies = [ "anyhow", "assert_cmd", @@ -2540,7 +2540,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.26.4" +version = "0.26.5" dependencies = [ "anyhow", "chrono", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.26.4" +version = "0.26.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index aef600f..2755a62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.26.4" +version = "0.26.5" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 57477a8..35682c6 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1503,8 +1503,22 @@ fn real_main() -> Result<()> { tj_core::event::Source::Cli, reason.clone().unwrap_or_else(|| "(closed)".into()), ); + let mut meta = serde_json::Map::new(); if let Some(r) = reason { - event.meta = serde_json::json!({"reason": r}); + meta.insert("reason".into(), serde_json::Value::String(r)); + } + // 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 + // db::index_event — never fails the close. + let arts = tj_core::harvest::harvest(&cwd); + if !arts.is_empty() { + if let Ok(v) = serde_json::to_value(&arts) { + meta.insert("artifacts".into(), v); + } + } + if !meta.is_empty() { + event.meta = serde_json::Value::Object(meta); } let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 341fbab..b73a9da 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -5728,3 +5728,63 @@ fn complete_retitles_and_closes_via_fake_backend() { .stdout(contains("status: closed")) .stdout(contains("Refunded the missing half")); } + +#[test] +fn close_harvests_git_commit_and_branch_into_pack() { + use std::process::Command as PCommand; + let dir = assert_fs::TempDir::new().unwrap(); + let proj = dir.path().join("repo"); + std::fs::create_dir_all(&proj).unwrap(); + + // Minimal git repo on a named branch with one commit. + let git = |args: &[&str]| { + PCommand::new("git") + .current_dir(&proj) + .args(args) + .output() + .unwrap(); + }; + git(&["init", "-q"]); + git(&["config", "user.email", "t@t.io"]); + git(&["config", "user.name", "T"]); + git(&["checkout", "-q", "-b", "feat/harvest-me"]); + std::fs::write(proj.join("f.txt"), "hi").unwrap(); + git(&["add", "."]); + git(&["commit", "-q", "-m", "init"]); + + // Create + close a task from inside the repo. + let task_id = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .current_dir(&proj) + .args(["create", "Harvest test"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap() + .trim() + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .current_dir(&proj) + .args(["close", &task_id, "--reason", "done"]) + .assert() + .success(); + + // The pack's Artifacts carries the branch (deterministic git harvest). + // gh may be absent/unauthed in CI, so we don't assert the PR url. + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .current_dir(&proj) + .args(["pack", &task_id, "--mode", "full"]) + .assert() + .success() + .stdout(contains("feat/harvest-me")); +} diff --git a/crates/tj-core/src/db.rs b/crates/tj-core/src/db.rs index 026081e..ac9671a 100644 --- a/crates/tj-core/src/db.rs +++ b/crates/tj-core/src/db.rs @@ -861,7 +861,19 @@ pub fn index_event(conn: &Connection, event: &Event) -> anyhow::Result<()> { // IDs, file paths, branch names) out of the event text. Storing // per-event so reclassify can recompute without touching foreign // events; pack aggregates and dedupes across events at render time. - let artifacts = crate::artifacts::extract(&event.text); + let mut artifacts = crate::artifacts::extract(&event.text); + // v0.26.5: structured artifacts harvested deterministically at close + // (git/gh: PR url, commit, branch) ride in `event.meta["artifacts"]`. + // Merge them so reliable refs land without depending on the lossy text + // regex — this is what turns a closed task into a clickable Loom card. + if let Some(meta_arts) = event + .meta + .get("artifacts") + .cloned() + .and_then(|v| serde_json::from_value::(v).ok()) + { + artifacts.merge(meta_arts); + } let artifacts_json = if artifacts.is_empty() { None } else { @@ -1471,6 +1483,37 @@ mod tests { ) } + #[test] + fn index_event_merges_structured_meta_artifacts() { + let d = TempDir::new().unwrap(); + let conn = open(d.path().join("s.sqlite")).unwrap(); + // Close-time harvest writes deterministic refs into meta.artifacts; + // the event text itself has no scrapeable tokens. + let mut ev = make_text_event("closed: shipped the Loom spine"); + ev.meta = serde_json::json!({ + "artifacts": { + "pr_urls": ["https://github.com/o/r/pull/51"], + "commit_hashes": ["75f65e2"], + "branch_names": ["feat/clean-pack"], + } + }); + index_event(&conn, &ev).unwrap(); + + let arts = task_artifacts(&conn, "tj-x").unwrap(); + assert!( + arts.pr_urls.iter().any(|p| p.contains("/pull/51")), + "pr merged" + ); + assert!( + arts.commit_hashes.iter().any(|c| c == "75f65e2"), + "commit merged" + ); + assert!( + arts.branch_names.iter().any(|b| b == "feat/clean-pack"), + "branch merged" + ); + } + #[test] fn embed_pending_embeds_all_then_is_idempotent() { let d = TempDir::new().unwrap(); diff --git a/crates/tj-core/src/harvest.rs b/crates/tj-core/src/harvest.rs new file mode 100644 index 0000000..933e909 --- /dev/null +++ b/crates/tj-core/src/harvest.rs @@ -0,0 +1,130 @@ +//! Deterministic close-time artifact harvest. +//! +//! When a task closes we want its resume pack to carry the *real* refs of what +//! shipped — the commit, the branch, the PR — as structured [`Artifacts`], not +//! as hopeful regex scrapes of free-form prose. This module shells out to +//! `git`/`gh` in the task's repo and returns whatever it can find. +//! +//! Strictly best-effort and side-effect free: a missing repo, a detached HEAD, +//! an absent `gh`, or no PR for the branch simply yields fewer artifacts. It +//! NEVER errors and NEVER runs a model — this is the cheap, deterministic +//! Layer-2 of the "perfect pack at close" design. The pure [`build`] decides +//! what to keep so the filtering is unit-testable without a live repo. + +use crate::artifacts::Artifacts; +use std::path::Path; +use std::process::Command; + +/// Pure assembler: turn the raw `(branch, commit, pr_url)` git/gh outputs into +/// a clean [`Artifacts`], dropping the values that aren't real refs — a +/// detached HEAD (`"HEAD"`), empty strings, or a non-http PR line. Separated +/// from the IO so the keep/drop rules can be tested without spawning git. +pub fn build(branch: Option, commit: Option, pr_url: Option) -> Artifacts { + let mut a = Artifacts::default(); + if let Some(b) = branch { + let b = b.trim(); + // "HEAD" means detached — not a branch name worth recording. + if !b.is_empty() && b != "HEAD" { + a.branch_names.push(b.to_string()); + } + } + if let Some(c) = commit { + let c = c.trim(); + if !c.is_empty() { + a.commit_hashes.push(c.to_string()); + } + } + if let Some(u) = pr_url { + let u = u.trim(); + if u.starts_with("http") { + a.pr_urls.push(u.to_string()); + } + } + a +} + +/// Harvest commit/branch/PR refs from the git repo at `dir`. Best-effort; +/// returns an empty [`Artifacts`] when `dir` is not a repo or the tools are +/// absent. Used at task close to stamp deterministic refs onto the close event. +pub fn harvest(dir: &Path) -> Artifacts { + let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]); + let commit = git(dir, &["rev-parse", "--short", "HEAD"]); + let pr_url = gh_pr_url(dir); + build(branch, commit, pr_url) +} + +/// Run `git -C ` and return trimmed stdout, or `None` on any +/// failure (missing git, not a repo, non-zero exit). +fn git(dir: &Path, args: &[&str]) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +/// Best-effort PR URL for the repo's current branch via `gh`. `None` when `gh` +/// is absent, unauthenticated, or the branch has no PR. May make a network +/// call, so it is the slowest part of the harvest — still bounded to one +/// short-lived child and never blocks the close on failure. +fn gh_pr_url(dir: &Path) -> Option { + let out = Command::new("gh") + .args(["pr", "view", "--json", "url", "-q", ".url"]) + .current_dir(dir) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.starts_with("http") { + Some(s) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_keeps_real_refs() { + let a = build( + Some("feat/clean-pack".into()), + Some("75f65e2".into()), + Some("https://github.com/o/r/pull/51".into()), + ); + assert_eq!(a.branch_names, vec!["feat/clean-pack"]); + assert_eq!(a.commit_hashes, vec!["75f65e2"]); + assert_eq!(a.pr_urls, vec!["https://github.com/o/r/pull/51"]); + } + + #[test] + fn build_drops_detached_head_empty_and_non_http() { + let a = build( + Some("HEAD".into()), + Some(" ".into()), + Some("no pull request".into()), + ); + assert!( + a.is_empty(), + "detached HEAD + empty commit + non-url PR all dropped" + ); + } + + #[test] + fn build_tolerates_all_absent() { + assert!(build(None, None, None).is_empty()); + } +} diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index de685c8..ca9c02d 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -58,6 +58,7 @@ pub mod event; pub mod finalize; pub mod frontmatter; pub mod fts; +pub mod harvest; pub mod llm; pub mod memory; pub mod pack; diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index 344c7fe..902c5a6 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -670,6 +670,18 @@ impl TaskJournalServer { if let Some(t) = &p.outcome_tag { meta.insert("outcome_tag".into(), serde_json::Value::String(t.clone())); } + // 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 + // structured (merged in db::index_event) — never fails close. + if let Ok(dir) = std::env::current_dir() { + let arts = tj_core::harvest::harvest(&dir); + if !arts.is_empty() { + if let Ok(v) = serde_json::to_value(&arts) { + meta.insert("artifacts".into(), v); + } + } + } event.meta = serde_json::Value::Object(meta); tj_core::session_id::stamp_session_id( &mut event.meta,