Skip to content

Commit 5cd381a

Browse files
committed
feat(v0.6.0): MCP parity, score-based linking, hygiene CLIs
Closes the v0.5.0 backlog items 1-7 in one batch. MCP parity: - task_create accepts goal; task_close accepts outcome_tag with the same enum validation as the CLI. Agents can now drive the journal end-to-end without falling back to the CLI for new fields. Smarter linking (Phase C+): - db::find_related_tasks scores by linked_issue (1.0) + commit_hash (0.8) + file path (0.3). Replaces the linked-issue-only scan in auto-open. Picks up cross-task continuations even when the user prompt didn't include a ticket id. - Pack splits `linked:tj-xxx` external entries into a dedicated **Linked**: block with the live status of each pointer. - Artifact extractor now captures `.docs/specs/auth.md`, `.github/workflows/ci.yml` and other dot-prefixed dirs. Hygiene CLI: - `task-journal stale [--days 7]` lists open tasks idle past the threshold with a hint to close-with-abandoned. - `task-journal pending-gc [--days 7]` deletes pending classifier payloads older than the threshold (post-outage cleanup). Classifier protocol: - ClassifyOutput.artifacts (Option, #[serde(default)]) — wire is ready for richer model output without breaking old payloads. Tests: 53 tj-cli + 147 tj-core, clippy/fmt clean.
1 parent ca9cefd commit 5cd381a

16 files changed

Lines changed: 396 additions & 32 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
},
77
"metadata": {
88
"description": "Task Journal — append-only reasoning chain memory for AI-coding tasks",
9-
"version": "0.5.0"
9+
"version": "0.6.0"
1010
},
1111
"plugins": [
1212
{
1313
"name": "task-journal",
1414
"source": "./plugin",
1515
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
16-
"version": "0.5.0",
16+
"version": "0.6.0",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.6.0] - 2026-05-08
11+
12+
Backlog cleanup: MCP brought in line with CLI, score-based linking,
13+
TUI/pack split out a Linked block, hygiene commands for stale tasks
14+
and pending GC, and the classifier protocol got an artifacts field
15+
ready for richer model output.
16+
17+
### Added — MCP parity
18+
- `task_create` MCP tool now accepts `goal: Option<String>` and
19+
persists it via `set_task_goal` after writing the open event.
20+
- `task_close` MCP tool now accepts `outcome_tag: Option<String>`
21+
validated against `done|abandoned|superseded`. Outcome + tag
22+
both go into the tasks table and the close event meta.
23+
24+
### Added — Hygiene CLI commands
25+
- `task-journal stale [--days 7]` lists open tasks whose last event
26+
crossed the inactivity threshold. Sorted by idle time descending.
27+
Hint at the bottom suggests close-with-abandoned for the obvious
28+
cases.
29+
- `task-journal pending-gc [--days 7]` deletes pending classifier
30+
payloads older than the threshold. Useful after a long classifier
31+
outage when the queue stops being recoverable.
32+
33+
### Added — Smarter linking
34+
- `db::find_related_tasks` scores tasks by overlap on
35+
`linked_issue` (1.0), `commit_hash` (0.8), and `file path` (0.3).
36+
Replaces the linked-issue-only scan inside auto-open.
37+
- Pack render splits `linked:tj-xxx` entries into a dedicated
38+
`**Linked**:` block with the live status of each pointer (`open`
39+
/ `closed` / `unknown`). Other external references stay in
40+
`**External**`.
41+
- Artifact extractor now captures dot-prefixed directories
42+
(`.docs/specs/auth.md`, `.github/workflows/ci.yml`).
43+
44+
### Added — Classifier protocol
45+
- `ClassifyOutput.artifacts: Option<Artifacts>` (with `#[serde(default)]`
46+
for backwards compat). Field is ready for the next prompt
47+
iteration that will instruct the model to return structured
48+
artifacts; current behaviour unchanged (regex extraction still
49+
the source of truth).
50+
51+
### Tests
52+
- 1 new unit test for dot-prefixed directory extraction.
53+
- All previous tests updated for the new External/Linked split.
54+
1055
## [0.5.0] - 2026-05-08
1156

1257
Auto-everything release. Phase B + C of the v0.5.0 plan land

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.5.0"
10+
version = "0.6.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
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.5.0", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.6.0", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 102 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,23 @@ enum Commands {
646646
#[arg(long)]
647647
reason: Option<String>,
648648
},
649+
/// List open tasks with no activity for N+ days. Use to clean up
650+
/// tasks that auto-opened, got a few events, then went silent —
651+
/// candidates for `task-journal close --outcome-tag abandoned`.
652+
Stale {
653+
/// Inactivity threshold in days. Default 7.
654+
#[arg(long, default_value_t = 7)]
655+
days: i64,
656+
},
657+
/// Garbage-collect the pending classifier queue. Removes entries
658+
/// older than N days OR marked dead by retry exhaustion. Run after
659+
/// classifier auth was broken for a while and the queue grew
660+
/// stale.
661+
PendingGc {
662+
/// Age threshold in days. Default 7.
663+
#[arg(long, default_value_t = 7)]
664+
days: i64,
665+
},
649666
/// Set or update the goal of an existing task.
650667
Goal {
651668
task_id: String,
@@ -1026,6 +1043,73 @@ fn main() -> Result<()> {
10261043
writer.flush_durable()?;
10271044
println!("{}", event.event_id);
10281045
}
1046+
Commands::Stale { days } => {
1047+
let cwd = std::env::current_dir()?;
1048+
let project_hash = tj_core::project_hash::from_path(&cwd)?;
1049+
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
1050+
let state_path = tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite"));
1051+
let conn = tj_core::db::open(&state_path)?;
1052+
if events_path.exists() {
1053+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
1054+
}
1055+
let stale = tj_core::db::stale_tasks(&conn, days)?;
1056+
if stale.is_empty() {
1057+
println!("(no stale tasks — all open tasks active within {days} days)");
1058+
} else {
1059+
println!("# Stale tasks (idle ≥ {days} days)\n");
1060+
for t in stale {
1061+
println!(
1062+
"{} {} days idle {} {}",
1063+
t.task_id, t.days_idle, t.last_event_at, t.title
1064+
);
1065+
}
1066+
println!(
1067+
"\nClose abandoned ones with: task-journal close <id> --outcome-tag abandoned --reason <why>"
1068+
);
1069+
}
1070+
}
1071+
Commands::PendingGc { days } => {
1072+
let pending_dir = tj_core::paths::events_dir()?
1073+
.parent()
1074+
.ok_or_else(|| anyhow::anyhow!("events_dir has no parent"))?
1075+
.join("pending");
1076+
if !pending_dir.exists() {
1077+
println!("(no pending dir — nothing to gc)");
1078+
return Ok(());
1079+
}
1080+
let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
1081+
let mut removed = 0usize;
1082+
for entry in std::fs::read_dir(&pending_dir)? {
1083+
let entry = entry?;
1084+
let path = entry.path();
1085+
if path.extension().and_then(|s| s.to_str()) != Some("json") {
1086+
continue;
1087+
}
1088+
// Prefer the file's mtime over JSON parsing — pending
1089+
// payloads include their own queued_at but are not
1090+
// guaranteed parseable when the classifier corrupted
1091+
// input mid-stream.
1092+
let mtime = entry
1093+
.metadata()
1094+
.and_then(|m| m.modified())
1095+
.ok()
1096+
.and_then(|t| {
1097+
chrono::DateTime::<chrono::Utc>::from(t)
1098+
.signed_duration_since(cutoff)
1099+
.num_seconds()
1100+
.into()
1101+
});
1102+
if let Some(secs) = mtime {
1103+
if secs < 0 && std::fs::remove_file(&path).is_ok() {
1104+
removed += 1;
1105+
}
1106+
}
1107+
}
1108+
println!(
1109+
"removed {} stale pending entries (older than {} days)",
1110+
removed, days
1111+
);
1112+
}
10291113
Commands::Reopen { task_id, reason } => {
10301114
// The Reopen event itself flips tasks.status back to open
10311115
// when ingested (db::apply_lifecycle handles this). The CLI
@@ -2070,27 +2154,29 @@ fn auto_open_task_from_prompt(
20702154
tj_core::db::set_task_goal(conn, &task_id, &goal)?;
20712155
}
20722156

2073-
// v0.5.0 Phase C: if the prompt mentions any tracked issue
2074-
// identifiers (FIN-868, JIRA-123…), search the journal for prior
2075-
// tasks that referenced the same issue. Auto-link them via the
2076-
// External column and warn on stderr when the prior is closed —
2077-
// user can reopen it instead of starting fresh.
2157+
// v0.5.0 Phase C / v0.6.0: score-based linking. Pull artifacts
2158+
// from the prompt — ticket ids, commit hashes, file paths — then
2159+
// ask the journal which prior tasks share enough signal to be a
2160+
// probable continuation. Anything with score > 0 gets linked via
2161+
// External; the strongest closed match also triggers a stderr
2162+
// hint so the user can reopen instead of accumulating duplicates.
20782163
let prompt_arts = tj_core::artifacts::extract(prompt);
2079-
if !prompt_arts.linked_issues.is_empty() {
2080-
let related = tj_core::db::find_tasks_by_linked_issues(conn, &prompt_arts.linked_issues)?;
2081-
for (other_id, status) in related {
2082-
if other_id == task_id {
2164+
if !prompt_arts.is_empty() {
2165+
let related = tj_core::db::find_related_tasks(conn, &prompt_arts)?;
2166+
let mut warned = false;
2167+
for r in related.iter().take(5) {
2168+
if r.task_id == task_id {
20832169
continue;
20842170
}
2085-
let _ = tj_core::db::add_task_external(conn, &task_id, &format!("linked:{other_id}"));
2086-
if status == "closed" {
2171+
let _ =
2172+
tj_core::db::add_task_external(conn, &task_id, &format!("linked:{}", r.task_id));
2173+
if !warned && r.status == "closed" {
20872174
eprintln!(
2088-
"task-journal: prompt references issue(s) {}, which appear in closed task {} — \
2089-
run `task-journal reopen {}` if this is a continuation rather than new scope.",
2090-
prompt_arts.linked_issues.join(","),
2091-
other_id,
2092-
other_id
2175+
"task-journal: this prompt looks like a continuation of closed task {} \
2176+
(score {:.1}) — run `task-journal reopen {}` if it is.",
2177+
r.task_id, r.score, r.task_id
20932178
);
2179+
warned = true;
20942180
}
20952181
}
20962182
}

crates/tj-cli/tests/cli.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2013,7 +2013,11 @@ fn auto_open_links_to_prior_task_referencing_same_issue() {
20132013
.args(["pack", &new_id, "--mode", "full"])
20142014
.assert()
20152015
.success()
2016-
.stdout(contains(format!("linked:{}", prior)));
2016+
// v0.6.0: linked entries surface in their own **Linked** block
2017+
// instead of mashed into External, with the prior task's
2018+
// current status annotated next to the id.
2019+
.stdout(contains("**Linked**:"))
2020+
.stdout(contains(format!("- {} [closed]", prior)));
20172021
}
20182022

20192023
#[test]

crates/tj-core/src/artifacts.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,13 @@ pub fn extract(text: &str) -> Artifacts {
9595
);
9696

9797
// File paths — heuristic: path-like tokens with at least one slash
98-
// (and an extension) OR a leading ./ . Tight enough to skip prose,
99-
// loose enough to catch the common cases (src/foo.rs, ./bar.ts,
98+
// (and an extension) OR a leading ./ . Path segments allow a
99+
// leading dot so `.docs/specs/auth.md`, `.github/workflows/ci.yml`
100+
// etc are captured as artifacts. Tight enough to skip prose, loose
101+
// enough to catch the common cases (src/foo.rs, ./bar.ts,
100102
// crates/tj-core/src/db.rs).
101103
static_re(
102-
r"(?:\./|[A-Za-z0-9_\-]+/)+[A-Za-z0-9_.\-]+\.[A-Za-z0-9]{1,8}\b",
104+
r"(?:\./|\.?[A-Za-z0-9_\-]+/)+[A-Za-z0-9_.\-]+\.[A-Za-z0-9]{1,8}\b",
103105
|m| a.files.push(m.to_string()),
104106
text,
105107
);
@@ -183,6 +185,16 @@ mod tests {
183185
assert!(a.files.contains(&"./README.md".to_string()));
184186
}
185187

188+
#[test]
189+
fn extracts_dot_prefixed_dirs() {
190+
// .docs/specs/*.md, .github/workflows/*.yml — leading-dot dirs
191+
// are spec/config holders we want surfaced as artifacts so the
192+
// pack ties decisions back to the document that justified them.
193+
let a = extract("see .docs/specs/auth.md and .github/workflows/ci.yml");
194+
assert!(a.files.contains(&".docs/specs/auth.md".to_string()));
195+
assert!(a.files.contains(&".github/workflows/ci.yml".to_string()));
196+
}
197+
186198
#[test]
187199
fn extracts_branch_names() {
188200
let a = extract("git checkout -b FIN-868-fix-paygate-fee then switch -c hotfix/abc");

crates/tj-core/src/classifier/mock.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mod tests {
2626
confidence: 0.95,
2727
evidence_strength: None,
2828
suggested_text: "...".into(),
29+
artifacts: None,
2930
},
3031
};
3132
let out = m

crates/tj-core/src/classifier/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ pub struct ClassifyOutput {
2525
pub confidence: f64,
2626
pub evidence_strength: Option<EvidenceStrength>,
2727
pub suggested_text: String,
28+
/// v0.6.0: optional structured artifacts the classifier extracted
29+
/// directly. When absent (old protocol or model didn't bother),
30+
/// the journal falls back to regex extraction in
31+
/// `db::ingest_new_events`. When present, the two sets are merged
32+
/// at ingest time so the model can surface artifacts the regex
33+
/// would miss (e.g. ticket ids in non-ASCII brackets).
34+
#[serde(default)]
35+
pub artifacts: Option<crate::artifacts::Artifacts>,
2836
}
2937

3038
pub trait Classifier: Send + Sync {

0 commit comments

Comments
 (0)