Skip to content

Commit aff3a8b

Browse files
Shahinyanmclaude
andauthored
feat(close): harvest git/gh refs into structured artifacts at close (#53)
Layer-2 of the "perfect pack at close" design. When a task closes, task_close (MCP) and close (CLI) now run a best-effort, deterministic git/gh harvest — current commit, branch, and PR URL (when gh is available) — and stamp them onto the close event as structured artifacts in meta["artifacts"]. index_event merges those with the existing text-scrape, so the resume pack renders real refs under Artifacts (commits/branches/PRs) instead of relying on the agent typing them into prose. No LLM, no judgment, never fails the close. New tj_core::harvest module (pure `build` + IO `harvest`), meta.artifacts merge in db::index_event, and an end-to-end CLI test that closes inside a git repo and asserts the branch lands in the pack. Bump 0.26.5. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d54a042 commit aff3a8b

9 files changed

Lines changed: 283 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.26.5] - 2026-06-17
11+
12+
### Added
13+
- **Closed tasks become a clickable ledger.** When a task is closed,
14+
`task_close` / `close` now harvests the real refs of what shipped — the
15+
current commit, branch, and (when `gh` is available) the PR URL — straight
16+
from `git`/`gh` and stamps them onto the close event as structured
17+
artifacts. The resume pack renders them under **Artifacts** (`commits:`,
18+
`branches:`, `PRs:`), so a month later the task shows *where it landed*
19+
without relying on the agent having typed the refs into prose. Deterministic
20+
and best-effort: no repo, no `gh`, or a detached HEAD just yields fewer
21+
artifacts and never fails the close. Structured artifacts ride in
22+
`event.meta["artifacts"]` and are merged with the existing text-scrape in
23+
`db::index_event`.
24+
1025
## [0.26.4] - 2026-06-17
1126

1227
### Fixed
@@ -17,8 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1732
failed every classification, silently piling chunks into the pending queue.
1833
The classifier now feeds the prompt on **stdin** (like the `complete`/enrich
1934
and dream backends already did), so chunks classify instead of dead-lettering.
20-
Run `task-journal pending retry` once to drain a backlog accumulated by the
21-
old behavior.
35+
The backlog drains on its own as the capture hook re-spawns the classify
36+
worker — no operator action needed.
2237

2338
## [0.26.3] - 2026-06-16
2439

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.26.4"
10+
version = "0.26.5"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/src/main.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1503,8 +1503,22 @@ fn real_main() -> Result<()> {
15031503
tj_core::event::Source::Cli,
15041504
reason.clone().unwrap_or_else(|| "(closed)".into()),
15051505
);
1506+
let mut meta = serde_json::Map::new();
15061507
if let Some(r) = reason {
1507-
event.meta = serde_json::json!({"reason": r});
1508+
meta.insert("reason".into(), serde_json::Value::String(r));
1509+
}
1510+
// Layer-2 close harvest: stamp deterministic git/gh refs (commit,
1511+
// branch, PR) so the closed pack reads as a clickable ledger of
1512+
// what shipped. Best-effort; structured artifacts are merged in
1513+
// db::index_event — never fails the close.
1514+
let arts = tj_core::harvest::harvest(&cwd);
1515+
if !arts.is_empty() {
1516+
if let Ok(v) = serde_json::to_value(&arts) {
1517+
meta.insert("artifacts".into(), v);
1518+
}
1519+
}
1520+
if !meta.is_empty() {
1521+
event.meta = serde_json::Value::Object(meta);
15081522
}
15091523

15101524
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;

crates/tj-cli/tests/cli.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5728,3 +5728,63 @@ fn complete_retitles_and_closes_via_fake_backend() {
57285728
.stdout(contains("status: closed"))
57295729
.stdout(contains("Refunded the missing half"));
57305730
}
5731+
5732+
#[test]
5733+
fn close_harvests_git_commit_and_branch_into_pack() {
5734+
use std::process::Command as PCommand;
5735+
let dir = assert_fs::TempDir::new().unwrap();
5736+
let proj = dir.path().join("repo");
5737+
std::fs::create_dir_all(&proj).unwrap();
5738+
5739+
// Minimal git repo on a named branch with one commit.
5740+
let git = |args: &[&str]| {
5741+
PCommand::new("git")
5742+
.current_dir(&proj)
5743+
.args(args)
5744+
.output()
5745+
.unwrap();
5746+
};
5747+
git(&["init", "-q"]);
5748+
git(&["config", "user.email", "t@t.io"]);
5749+
git(&["config", "user.name", "T"]);
5750+
git(&["checkout", "-q", "-b", "feat/harvest-me"]);
5751+
std::fs::write(proj.join("f.txt"), "hi").unwrap();
5752+
git(&["add", "."]);
5753+
git(&["commit", "-q", "-m", "init"]);
5754+
5755+
// Create + close a task from inside the repo.
5756+
let task_id = String::from_utf8(
5757+
Command::cargo_bin("task-journal")
5758+
.unwrap()
5759+
.env("XDG_DATA_HOME", dir.path())
5760+
.current_dir(&proj)
5761+
.args(["create", "Harvest test"])
5762+
.assert()
5763+
.success()
5764+
.get_output()
5765+
.stdout
5766+
.clone(),
5767+
)
5768+
.unwrap()
5769+
.trim()
5770+
.to_string();
5771+
5772+
Command::cargo_bin("task-journal")
5773+
.unwrap()
5774+
.env("XDG_DATA_HOME", dir.path())
5775+
.current_dir(&proj)
5776+
.args(["close", &task_id, "--reason", "done"])
5777+
.assert()
5778+
.success();
5779+
5780+
// The pack's Artifacts carries the branch (deterministic git harvest).
5781+
// gh may be absent/unauthed in CI, so we don't assert the PR url.
5782+
Command::cargo_bin("task-journal")
5783+
.unwrap()
5784+
.env("XDG_DATA_HOME", dir.path())
5785+
.current_dir(&proj)
5786+
.args(["pack", &task_id, "--mode", "full"])
5787+
.assert()
5788+
.success()
5789+
.stdout(contains("feat/harvest-me"));
5790+
}

crates/tj-core/src/db.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,7 +861,19 @@ pub fn index_event(conn: &Connection, event: &Event) -> anyhow::Result<()> {
861861
// IDs, file paths, branch names) out of the event text. Storing
862862
// per-event so reclassify can recompute without touching foreign
863863
// events; pack aggregates and dedupes across events at render time.
864-
let artifacts = crate::artifacts::extract(&event.text);
864+
let mut artifacts = crate::artifacts::extract(&event.text);
865+
// v0.26.5: structured artifacts harvested deterministically at close
866+
// (git/gh: PR url, commit, branch) ride in `event.meta["artifacts"]`.
867+
// Merge them so reliable refs land without depending on the lossy text
868+
// regex — this is what turns a closed task into a clickable Loom card.
869+
if let Some(meta_arts) = event
870+
.meta
871+
.get("artifacts")
872+
.cloned()
873+
.and_then(|v| serde_json::from_value::<crate::artifacts::Artifacts>(v).ok())
874+
{
875+
artifacts.merge(meta_arts);
876+
}
865877
let artifacts_json = if artifacts.is_empty() {
866878
None
867879
} else {
@@ -1471,6 +1483,37 @@ mod tests {
14711483
)
14721484
}
14731485

1486+
#[test]
1487+
fn index_event_merges_structured_meta_artifacts() {
1488+
let d = TempDir::new().unwrap();
1489+
let conn = open(d.path().join("s.sqlite")).unwrap();
1490+
// Close-time harvest writes deterministic refs into meta.artifacts;
1491+
// the event text itself has no scrapeable tokens.
1492+
let mut ev = make_text_event("closed: shipped the Loom spine");
1493+
ev.meta = serde_json::json!({
1494+
"artifacts": {
1495+
"pr_urls": ["https://github.com/o/r/pull/51"],
1496+
"commit_hashes": ["75f65e2"],
1497+
"branch_names": ["feat/clean-pack"],
1498+
}
1499+
});
1500+
index_event(&conn, &ev).unwrap();
1501+
1502+
let arts = task_artifacts(&conn, "tj-x").unwrap();
1503+
assert!(
1504+
arts.pr_urls.iter().any(|p| p.contains("/pull/51")),
1505+
"pr merged"
1506+
);
1507+
assert!(
1508+
arts.commit_hashes.iter().any(|c| c == "75f65e2"),
1509+
"commit merged"
1510+
);
1511+
assert!(
1512+
arts.branch_names.iter().any(|b| b == "feat/clean-pack"),
1513+
"branch merged"
1514+
);
1515+
}
1516+
14741517
#[test]
14751518
fn embed_pending_embeds_all_then_is_idempotent() {
14761519
let d = TempDir::new().unwrap();

crates/tj-core/src/harvest.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
//! Deterministic close-time artifact harvest.
2+
//!
3+
//! When a task closes we want its resume pack to carry the *real* refs of what
4+
//! shipped — the commit, the branch, the PR — as structured [`Artifacts`], not
5+
//! as hopeful regex scrapes of free-form prose. This module shells out to
6+
//! `git`/`gh` in the task's repo and returns whatever it can find.
7+
//!
8+
//! Strictly best-effort and side-effect free: a missing repo, a detached HEAD,
9+
//! an absent `gh`, or no PR for the branch simply yields fewer artifacts. It
10+
//! NEVER errors and NEVER runs a model — this is the cheap, deterministic
11+
//! Layer-2 of the "perfect pack at close" design. The pure [`build`] decides
12+
//! what to keep so the filtering is unit-testable without a live repo.
13+
14+
use crate::artifacts::Artifacts;
15+
use std::path::Path;
16+
use std::process::Command;
17+
18+
/// Pure assembler: turn the raw `(branch, commit, pr_url)` git/gh outputs into
19+
/// a clean [`Artifacts`], dropping the values that aren't real refs — a
20+
/// detached HEAD (`"HEAD"`), empty strings, or a non-http PR line. Separated
21+
/// from the IO so the keep/drop rules can be tested without spawning git.
22+
pub fn build(branch: Option<String>, commit: Option<String>, pr_url: Option<String>) -> Artifacts {
23+
let mut a = Artifacts::default();
24+
if let Some(b) = branch {
25+
let b = b.trim();
26+
// "HEAD" means detached — not a branch name worth recording.
27+
if !b.is_empty() && b != "HEAD" {
28+
a.branch_names.push(b.to_string());
29+
}
30+
}
31+
if let Some(c) = commit {
32+
let c = c.trim();
33+
if !c.is_empty() {
34+
a.commit_hashes.push(c.to_string());
35+
}
36+
}
37+
if let Some(u) = pr_url {
38+
let u = u.trim();
39+
if u.starts_with("http") {
40+
a.pr_urls.push(u.to_string());
41+
}
42+
}
43+
a
44+
}
45+
46+
/// Harvest commit/branch/PR refs from the git repo at `dir`. Best-effort;
47+
/// returns an empty [`Artifacts`] when `dir` is not a repo or the tools are
48+
/// absent. Used at task close to stamp deterministic refs onto the close event.
49+
pub fn harvest(dir: &Path) -> Artifacts {
50+
let branch = git(dir, &["rev-parse", "--abbrev-ref", "HEAD"]);
51+
let commit = git(dir, &["rev-parse", "--short", "HEAD"]);
52+
let pr_url = gh_pr_url(dir);
53+
build(branch, commit, pr_url)
54+
}
55+
56+
/// Run `git -C <dir> <args>` and return trimmed stdout, or `None` on any
57+
/// failure (missing git, not a repo, non-zero exit).
58+
fn git(dir: &Path, args: &[&str]) -> Option<String> {
59+
let out = Command::new("git")
60+
.arg("-C")
61+
.arg(dir)
62+
.args(args)
63+
.output()
64+
.ok()?;
65+
if !out.status.success() {
66+
return None;
67+
}
68+
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
69+
if s.is_empty() {
70+
None
71+
} else {
72+
Some(s)
73+
}
74+
}
75+
76+
/// Best-effort PR URL for the repo's current branch via `gh`. `None` when `gh`
77+
/// is absent, unauthenticated, or the branch has no PR. May make a network
78+
/// call, so it is the slowest part of the harvest — still bounded to one
79+
/// short-lived child and never blocks the close on failure.
80+
fn gh_pr_url(dir: &Path) -> Option<String> {
81+
let out = Command::new("gh")
82+
.args(["pr", "view", "--json", "url", "-q", ".url"])
83+
.current_dir(dir)
84+
.output()
85+
.ok()?;
86+
if !out.status.success() {
87+
return None;
88+
}
89+
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
90+
if s.starts_with("http") {
91+
Some(s)
92+
} else {
93+
None
94+
}
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
#[test]
102+
fn build_keeps_real_refs() {
103+
let a = build(
104+
Some("feat/clean-pack".into()),
105+
Some("75f65e2".into()),
106+
Some("https://github.com/o/r/pull/51".into()),
107+
);
108+
assert_eq!(a.branch_names, vec!["feat/clean-pack"]);
109+
assert_eq!(a.commit_hashes, vec!["75f65e2"]);
110+
assert_eq!(a.pr_urls, vec!["https://github.com/o/r/pull/51"]);
111+
}
112+
113+
#[test]
114+
fn build_drops_detached_head_empty_and_non_http() {
115+
let a = build(
116+
Some("HEAD".into()),
117+
Some(" ".into()),
118+
Some("no pull request".into()),
119+
);
120+
assert!(
121+
a.is_empty(),
122+
"detached HEAD + empty commit + non-url PR all dropped"
123+
);
124+
}
125+
126+
#[test]
127+
fn build_tolerates_all_absent() {
128+
assert!(build(None, None, None).is_empty());
129+
}
130+
}

crates/tj-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ pub mod event;
5858
pub mod finalize;
5959
pub mod frontmatter;
6060
pub mod fts;
61+
pub mod harvest;
6162
pub mod llm;
6263
pub mod memory;
6364
pub mod pack;

crates/tj-mcp/src/main.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,18 @@ impl TaskJournalServer {
670670
if let Some(t) = &p.outcome_tag {
671671
meta.insert("outcome_tag".into(), serde_json::Value::String(t.clone()));
672672
}
673+
// Layer-2 close harvest: stamp deterministic git/gh refs
674+
// (commit, branch, PR) into the close event so the resume pack
675+
// reads as a clickable ledger of what shipped. Best-effort and
676+
// structured (merged in db::index_event) — never fails close.
677+
if let Ok(dir) = std::env::current_dir() {
678+
let arts = tj_core::harvest::harvest(&dir);
679+
if !arts.is_empty() {
680+
if let Ok(v) = serde_json::to_value(&arts) {
681+
meta.insert("artifacts".into(), v);
682+
}
683+
}
684+
}
673685
event.meta = serde_json::Value::Object(meta);
674686
tj_core::session_id::stamp_session_id(
675687
&mut event.meta,

0 commit comments

Comments
 (0)