Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.26.4"
version = "0.26.5"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
16 changes: 15 additions & 1 deletion crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
60 changes: 60 additions & 0 deletions crates/tj-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
45 changes: 44 additions & 1 deletion crates/tj-core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<crate::artifacts::Artifacts>(v).ok())
{
artifacts.merge(meta_arts);
}
let artifacts_json = if artifacts.is_empty() {
None
} else {
Expand Down Expand Up @@ -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();
Expand Down
130 changes: 130 additions & 0 deletions crates/tj-core/src/harvest.rs
Original file line number Diff line number Diff line change
@@ -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<String>, commit: Option<String>, pr_url: Option<String>) -> 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 <dir> <args>` and return trimmed stdout, or `None` on any
/// failure (missing git, not a repo, non-zero exit).
fn git(dir: &Path, args: &[&str]) -> Option<String> {
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<String> {
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());
}
}
1 change: 1 addition & 0 deletions crates/tj-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions crates/tj-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading