Skip to content

Commit f6f0f11

Browse files
Shahinyanmclaude
andauthored
feat: structured clickable artifact links {kind,url,label} (0.27.0) (#55)
* feat(artifacts): structured clickable links {kind,url,label} for the card Adds Artifacts.links rendered as [label](url) (kind) in the pack. Fed by: - harvest at close: resolves repo web URL (gh repo view) and emits clickable PR / commit / branch links (a bare commit hash becomes a real link) - artifact_add: new MCP tool + `task-journal artifact-add` CLI command so the agent can attach a doc / deploy / dashboard. Stored as a finding event whose meta.artifacts is merged by index_event — no new storage. Flat token vectors unchanged (still power search / relatedness). Bump 0.27.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deps): bump inter-crate tj-core pin to 0.27.0 The path deps pinned task-journal-core = "0.26.1" (^0.26.1 excludes 0.27.0); the first minor bump broke registry resolution in CI. Match the pin to the workspace version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 31948dc commit f6f0f11

11 files changed

Lines changed: 398 additions & 50 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.27.0] - 2026-06-17
11+
12+
### Added
13+
- **Clickable, typed artifact links on the task card.** `Artifacts` gains a
14+
`links: [{kind, url, label}]` field rendered in the pack as
15+
`[label](url) (kind)`. Two sources feed it:
16+
- **Auto at close** — the harvest now resolves the repo's web URL (`gh repo
17+
view`) and emits ready-to-click links for the PR (labelled `PR #N`), the
18+
commit (`…/commit/<sha>`), and the branch (`…/tree/<branch>`), so a bare
19+
commit hash becomes a real link.
20+
- **`artifact_add`** — a new MCP tool (and `task-journal artifact-add` CLI
21+
command) lets the agent attach arbitrary references — a design doc, a
22+
deploy/preview URL, a dashboard — as `artifact_add(task_id, kind, url,
23+
label)`. Stored as a `finding` event whose `meta.artifacts` is merged by
24+
`index_event`, so it surfaces on the card without new storage.
25+
26+
The flat token vectors (commit_hashes, pr_urls, …) are unchanged and still
27+
power artifact search / task relatedness.
28+
1029
## [0.26.6] - 2026-06-17
1130

1231
### Added

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.6"
10+
version = "0.27.0"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ default = ["embed"]
2323
embed = ["tj-core/embed"]
2424

2525
[dependencies]
26-
tj-core = { package = "task-journal-core", version = "0.26.1", path = "../tj-core", default-features = false }
26+
tj-core = { package = "task-journal-core", version = "0.27.0", path = "../tj-core", default-features = false }
2727
anyhow = { workspace = true }
2828
clap = { workspace = true }
2929
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,22 @@ enum Commands {
727727
#[arg(long)]
728728
outcome_tag: Option<String>,
729729
},
730+
/// Attach a clickable, typed link to a task (doc, deploy, dashboard,
731+
/// design, …). Renders under the pack's Artifacts as `[label](url)` so a
732+
/// host like the Loom board shows it on the task card. Writes a `finding`
733+
/// event carrying the link in `meta.artifacts`.
734+
ArtifactAdd {
735+
task_id: String,
736+
/// Short tag: `doc`, `deploy`, `dashboard`, `design`, `pr`, …
737+
#[arg(long)]
738+
kind: String,
739+
/// The link target (URL or path).
740+
#[arg(long)]
741+
url: String,
742+
/// Human label shown on the card.
743+
#[arg(long)]
744+
label: String,
745+
},
730746
/// Reopen a previously closed task (writes a `reopen` event and
731747
/// flips status back to `open`). Use when the same scope comes
732748
/// back, e.g. a regression on a shipped fix or a follow-up bug
@@ -1454,6 +1470,33 @@ fn real_main() -> Result<()> {
14541470
writer.flush_durable()?;
14551471
println!("{}", event.event_id);
14561472
}
1473+
Commands::ArtifactAdd {
1474+
task_id,
1475+
kind,
1476+
url,
1477+
label,
1478+
} => {
1479+
let cwd = std::env::current_dir()?;
1480+
let project_hash = tj_core::project_hash::from_path(&cwd)?;
1481+
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
1482+
std::fs::create_dir_all(events_path.parent().unwrap())?;
1483+
1484+
// A `finding` event carries the link in meta.artifacts; index_event
1485+
// merges it so it renders under the pack's Artifacts.
1486+
let mut event = tj_core::event::Event::new(
1487+
&task_id,
1488+
tj_core::event::EventType::Finding,
1489+
tj_core::event::Author::User,
1490+
tj_core::event::Source::Cli,
1491+
format!("📎 {kind}: {label} — {url}"),
1492+
);
1493+
event.meta = tj_core::artifacts::link_event_meta(&kind, &url, &label);
1494+
1495+
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
1496+
writer.append(&event)?;
1497+
writer.flush_durable()?;
1498+
println!("{}", event.event_id);
1499+
}
14571500
Commands::Close {
14581501
task_id,
14591502
reason,

crates/tj-cli/tests/cli.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5788,3 +5788,46 @@ fn close_harvests_git_commit_and_branch_into_pack() {
57885788
.success()
57895789
.stdout(contains("feat/harvest-me"));
57905790
}
5791+
5792+
#[test]
5793+
fn artifact_add_renders_clickable_link_in_pack() {
5794+
let dir = assert_fs::TempDir::new().unwrap();
5795+
let task_id = String::from_utf8(
5796+
Command::cargo_bin("task-journal")
5797+
.unwrap()
5798+
.env("XDG_DATA_HOME", dir.path())
5799+
.args(["create", "Card test"])
5800+
.assert()
5801+
.success()
5802+
.get_output()
5803+
.stdout
5804+
.clone(),
5805+
)
5806+
.unwrap()
5807+
.trim()
5808+
.to_string();
5809+
5810+
Command::cargo_bin("task-journal")
5811+
.unwrap()
5812+
.env("XDG_DATA_HOME", dir.path())
5813+
.args([
5814+
"artifact-add",
5815+
&task_id,
5816+
"--kind",
5817+
"doc",
5818+
"--url",
5819+
"https://example.com/spec.md",
5820+
"--label",
5821+
"Design spec",
5822+
])
5823+
.assert()
5824+
.success();
5825+
5826+
Command::cargo_bin("task-journal")
5827+
.unwrap()
5828+
.env("XDG_DATA_HOME", dir.path())
5829+
.args(["pack", &task_id, "--mode", "full"])
5830+
.assert()
5831+
.success()
5832+
.stdout(contains("[Design spec](https://example.com/spec.md) (doc)"));
5833+
}

crates/tj-core/src/artifacts.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ pub struct Artifacts {
2727
pub files: Vec<String>,
2828
#[serde(default, skip_serializing_if = "Vec::is_empty")]
2929
pub branch_names: Vec<String>,
30+
/// Clickable, typed links for rendering a task card. Additive to the flat
31+
/// token vectors above (which still power artifact search / relatedness):
32+
/// `links` carries a ready-to-click `{kind,url,label}` so a host like the
33+
/// Loom board doesn't reconstruct URLs. Harvested at close (PR/commit/
34+
/// branch) or attached by the agent via `artifact_add` (doc/deploy/…).
35+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
36+
pub links: Vec<ArtifactLink>,
37+
}
38+
39+
/// A typed, clickable reference. `kind` is a short tag (`pr`, `commit`,
40+
/// `branch`, `doc`, `deploy`, `issue`, …); `url` is the link; `label` is the
41+
/// human text shown on the card.
42+
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
43+
pub struct ArtifactLink {
44+
pub kind: String,
45+
pub url: String,
46+
pub label: String,
3047
}
3148

3249
impl Artifacts {
@@ -36,6 +53,7 @@ impl Artifacts {
3653
&& self.linked_issues.is_empty()
3754
&& self.files.is_empty()
3855
&& self.branch_names.is_empty()
56+
&& self.links.is_empty()
3957
}
4058

4159
/// Merge another `Artifacts` into self, preserving insertion order
@@ -54,9 +72,27 @@ impl Artifacts {
5472
}
5573
}
5674
}
75+
// links hold a struct, not a String, so they merge separately —
76+
// deduped by full {kind,url,label} equality.
77+
for l in other.links {
78+
if !self.links.iter().any(|x| x == &l) {
79+
self.links.push(l);
80+
}
81+
}
5782
}
5883
}
5984

85+
/// Build the `event.meta` payload that attaches one [`ArtifactLink`] to an
86+
/// event. `db::index_event` merges `meta["artifacts"]` into the event's
87+
/// artifacts, so a `Finding` (or any) event carrying this meta surfaces the
88+
/// link in the task's pack. Shared by the CLI `artifact-add` command and the
89+
/// MCP `artifact_add` tool so they store identical shapes.
90+
pub fn link_event_meta(kind: &str, url: &str, label: &str) -> serde_json::Value {
91+
serde_json::json!({
92+
"artifacts": { "links": [ { "kind": kind, "url": url, "label": label } ] }
93+
})
94+
}
95+
6096
/// Extract artifacts from a single piece of text (event body, prompt,
6197
/// tool output — anything stringly-typed). Idempotent and free of I/O.
6298
pub fn extract(text: &str) -> Artifacts {
@@ -252,6 +288,29 @@ mod tests {
252288
assert!(a.is_empty());
253289
}
254290

291+
#[test]
292+
fn merge_dedupes_links_by_full_identity() {
293+
let link = |k: &str, u: &str, l: &str| ArtifactLink {
294+
kind: k.into(),
295+
url: u.into(),
296+
label: l.into(),
297+
};
298+
let mut a = Artifacts {
299+
links: vec![link("pr", "u/pull/1", "PR #1")],
300+
..Default::default()
301+
};
302+
assert!(!a.is_empty());
303+
a.merge(Artifacts {
304+
links: vec![
305+
link("pr", "u/pull/1", "PR #1"), // dup → dropped
306+
link("doc", "u/spec.md", "Spec"), // new → kept
307+
],
308+
..Default::default()
309+
});
310+
assert_eq!(a.links.len(), 2);
311+
assert_eq!(a.links[1].kind, "doc");
312+
}
313+
255314
#[test]
256315
fn captures_short_pr_reference_but_not_bare_hash() {
257316
let a = extract("merged PR #51 and PR#52, see pull request #53");

0 commit comments

Comments
 (0)