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: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.27.0] - 2026-06-17

### Added
- **Clickable, typed artifact links on the task card.** `Artifacts` gains a
`links: [{kind, url, label}]` field rendered in the pack as
`[label](url) (kind)`. Two sources feed it:
- **Auto at close** — the harvest now resolves the repo's web URL (`gh repo
view`) and emits ready-to-click links for the PR (labelled `PR #N`), the
commit (`…/commit/<sha>`), and the branch (`…/tree/<branch>`), so a bare
commit hash becomes a real link.
- **`artifact_add`** — a new MCP tool (and `task-journal artifact-add` CLI
command) lets the agent attach arbitrary references — a design doc, a
deploy/preview URL, a dashboard — as `artifact_add(task_id, kind, url,
label)`. Stored as a `finding` event whose `meta.artifacts` is merged by
`index_event`, so it surfaces on the card without new storage.

The flat token vectors (commit_hashes, pr_urls, …) are unchanged and still
power artifact search / task relatedness.

## [0.26.6] - 2026-06-17

### Added
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.6"
version = "0.27.0"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ default = ["embed"]
embed = ["tj-core/embed"]

[dependencies]
tj-core = { package = "task-journal-core", version = "0.26.1", path = "../tj-core", default-features = false }
tj-core = { package = "task-journal-core", version = "0.27.0", path = "../tj-core", default-features = false }
anyhow = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
Expand Down
43 changes: 43 additions & 0 deletions crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,22 @@ enum Commands {
#[arg(long)]
outcome_tag: Option<String>,
},
/// Attach a clickable, typed link to a task (doc, deploy, dashboard,
/// design, …). Renders under the pack's Artifacts as `[label](url)` so a
/// host like the Loom board shows it on the task card. Writes a `finding`
/// event carrying the link in `meta.artifacts`.
ArtifactAdd {
task_id: String,
/// Short tag: `doc`, `deploy`, `dashboard`, `design`, `pr`, …
#[arg(long)]
kind: String,
/// The link target (URL or path).
#[arg(long)]
url: String,
/// Human label shown on the card.
#[arg(long)]
label: String,
},
/// Reopen a previously closed task (writes a `reopen` event and
/// flips status back to `open`). Use when the same scope comes
/// back, e.g. a regression on a shipped fix or a follow-up bug
Expand Down Expand Up @@ -1454,6 +1470,33 @@ fn real_main() -> Result<()> {
writer.flush_durable()?;
println!("{}", event.event_id);
}
Commands::ArtifactAdd {
task_id,
kind,
url,
label,
} => {
let cwd = std::env::current_dir()?;
let project_hash = tj_core::project_hash::from_path(&cwd)?;
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
std::fs::create_dir_all(events_path.parent().unwrap())?;

// A `finding` event carries the link in meta.artifacts; index_event
// merges it so it renders under the pack's Artifacts.
let mut event = tj_core::event::Event::new(
&task_id,
tj_core::event::EventType::Finding,
tj_core::event::Author::User,
tj_core::event::Source::Cli,
format!("📎 {kind}: {label} — {url}"),
);
event.meta = tj_core::artifacts::link_event_meta(&kind, &url, &label);

let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
writer.append(&event)?;
writer.flush_durable()?;
println!("{}", event.event_id);
}
Commands::Close {
task_id,
reason,
Expand Down
43 changes: 43 additions & 0 deletions crates/tj-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5788,3 +5788,46 @@ fn close_harvests_git_commit_and_branch_into_pack() {
.success()
.stdout(contains("feat/harvest-me"));
}

#[test]
fn artifact_add_renders_clickable_link_in_pack() {
let dir = assert_fs::TempDir::new().unwrap();
let task_id = String::from_utf8(
Command::cargo_bin("task-journal")
.unwrap()
.env("XDG_DATA_HOME", dir.path())
.args(["create", "Card test"])
.assert()
.success()
.get_output()
.stdout
.clone(),
)
.unwrap()
.trim()
.to_string();

Command::cargo_bin("task-journal")
.unwrap()
.env("XDG_DATA_HOME", dir.path())
.args([
"artifact-add",
&task_id,
"--kind",
"doc",
"--url",
"https://example.com/spec.md",
"--label",
"Design spec",
])
.assert()
.success();

Command::cargo_bin("task-journal")
.unwrap()
.env("XDG_DATA_HOME", dir.path())
.args(["pack", &task_id, "--mode", "full"])
.assert()
.success()
.stdout(contains("[Design spec](https://example.com/spec.md) (doc)"));
}
59 changes: 59 additions & 0 deletions crates/tj-core/src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ pub struct Artifacts {
pub files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub branch_names: Vec<String>,
/// Clickable, typed links for rendering a task card. Additive to the flat
/// token vectors above (which still power artifact search / relatedness):
/// `links` carries a ready-to-click `{kind,url,label}` so a host like the
/// Loom board doesn't reconstruct URLs. Harvested at close (PR/commit/
/// branch) or attached by the agent via `artifact_add` (doc/deploy/…).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<ArtifactLink>,
}

/// A typed, clickable reference. `kind` is a short tag (`pr`, `commit`,
/// `branch`, `doc`, `deploy`, `issue`, …); `url` is the link; `label` is the
/// human text shown on the card.
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactLink {
pub kind: String,
pub url: String,
pub label: String,
}

impl Artifacts {
Expand All @@ -36,6 +53,7 @@ impl Artifacts {
&& self.linked_issues.is_empty()
&& self.files.is_empty()
&& self.branch_names.is_empty()
&& self.links.is_empty()
}

/// Merge another `Artifacts` into self, preserving insertion order
Expand All @@ -54,9 +72,27 @@ impl Artifacts {
}
}
}
// links hold a struct, not a String, so they merge separately —
// deduped by full {kind,url,label} equality.
for l in other.links {
if !self.links.iter().any(|x| x == &l) {
self.links.push(l);
}
}
}
}

/// Build the `event.meta` payload that attaches one [`ArtifactLink`] to an
/// event. `db::index_event` merges `meta["artifacts"]` into the event's
/// artifacts, so a `Finding` (or any) event carrying this meta surfaces the
/// link in the task's pack. Shared by the CLI `artifact-add` command and the
/// MCP `artifact_add` tool so they store identical shapes.
pub fn link_event_meta(kind: &str, url: &str, label: &str) -> serde_json::Value {
serde_json::json!({
"artifacts": { "links": [ { "kind": kind, "url": url, "label": label } ] }
})
}

/// Extract artifacts from a single piece of text (event body, prompt,
/// tool output — anything stringly-typed). Idempotent and free of I/O.
pub fn extract(text: &str) -> Artifacts {
Expand Down Expand Up @@ -252,6 +288,29 @@ mod tests {
assert!(a.is_empty());
}

#[test]
fn merge_dedupes_links_by_full_identity() {
let link = |k: &str, u: &str, l: &str| ArtifactLink {
kind: k.into(),
url: u.into(),
label: l.into(),
};
let mut a = Artifacts {
links: vec![link("pr", "u/pull/1", "PR #1")],
..Default::default()
};
assert!(!a.is_empty());
a.merge(Artifacts {
links: vec![
link("pr", "u/pull/1", "PR #1"), // dup → dropped
link("doc", "u/spec.md", "Spec"), // new → kept
],
..Default::default()
});
assert_eq!(a.links.len(), 2);
assert_eq!(a.links[1].kind, "doc");
}

#[test]
fn captures_short_pr_reference_but_not_bare_hash() {
let a = extract("merged PR #51 and PR#52, see pull request #53");
Expand Down
Loading
Loading