Skip to content

Commit d65dd9f

Browse files
committed
feat(ingest): auto-open task on first prompt — v0.5.0 Phase A (v0.4.1)
UserPromptSubmit firing into an empty project no longer drops the prompt silently. The journal synthesizes a task on the fly with title=first line(80), goal=prompt(200), then continues the normal classifier pipeline so the same prompt becomes the first event. Removes the manual "task-journal create --goal" prerequisite that was blocking real-world usage — users do not call create. - auto_open_task_from_prompt() helper in tj-cli - meta.auto_opened=true marker on synthesized open events - TJ_AUTO_OPEN_TASKS=0 opt-out (default ON) - Only fires for UserPromptSubmit; PostToolUse/Stop never conjure tasks - 2 new integration tests cover both branches - Phase B (artifacts) and Phase C (linked_issue/reopen) tracked in .docs/plans/2026-05-08-v0.5.0-auto-everything.md 49/49 tj-cli tests pass; clippy/fmt clean.
1 parent 4ef2cfe commit d65dd9f

11 files changed

Lines changed: 286 additions & 13 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.4.0"
9+
"version": "0.4.1"
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.4.0",
16+
"version": "0.4.1",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# v0.5.0 — auto-everything
2+
3+
Epic: `tj-z1q3t489kt`
4+
5+
Цель: убрать ручную дисциплину `create --goal` / `external --add` /
6+
поиск старой задачи. Claude через хуки делает сам.
7+
8+
## Phase A — auto-create
9+
10+
**Trigger:** UserPromptSubmit хук, нет открытой задачи в текущем
11+
project_hash, либо последняя активность >24h назад.
12+
13+
**Логика:**
14+
1. Хук получает текст промпта
15+
2. Запрос FTS5 по closed-за-30-дней: совпадение по тикет-номерам
16+
(FIN-XXX, JIRA-XXX, INC-XXX), commit hash, ключевые слова
17+
3. Если sim > 0.7 → emit "candidate_reopen" event в pending,
18+
classifier предложит reopen старой
19+
4. Иначе → авто-create новой, goal = trim(первые 200 char промпта)
20+
5. Все последующие события сессии цепляются автоматом
21+
22+
**Опт-аут:** `TJ_AUTO_OPEN_TASKS=0`. По умолчанию ON.
23+
24+
## Phase B — artifacts auto-extract
25+
26+
**Колонка** `events_index.artifacts` уже есть (schema v003).
27+
28+
**Извлечение в classifier JSON:**
29+
- `commit_hash` — regex `[a-f0-9]{7,40}` в Bash output после
30+
`git commit` / `git log`
31+
- `pr_url` — regex `https://github\.com/.+/pull/\d+` в `gh pr create`
32+
output
33+
- `files` — все `tool_input.file_path` из Edit/Write tool calls
34+
- `linked_issue` — regex `[A-Z]+-\d+` в тексте промптов
35+
- `branch_name` — текущая ветка через `git symbolic-ref` при
36+
SessionStart
37+
38+
**Pack:** новый блок `**Artifacts**:` с группировкой commits / files
39+
/ PRs / issues.
40+
41+
**Reclassify:** новая команда `task-journal reclassify <id>`
42+
проходит по существующим событиям, вытаскивает артефакты regex'ами,
43+
заполняет `artifacts` колонку. Idempotent.
44+
45+
## Phase C — linked_issue / reopen suggest
46+
47+
**При новом событии classifier:**
48+
1. Смотрит open + closed-30d
49+
2. Считает score по: shared linked_issue (vec 1.0), commit hash overlap
50+
(0.8), file overlap >50% (0.7), FTS5 sim (0-0.5)
51+
3. Score > 0.7 + open task → cепляет к ней
52+
4. Score > 0.7 + closed task → emit "linked_to_closed" warning
53+
событие, в pack видно "🔗 linked to tj-xxx [closed]"
54+
5. Юзер решает: `task-journal reopen <id>` или
55+
`task-journal external <new> --add tj-xxx`
56+
57+
**Reopen:** новая команда `task-journal reopen <id>`
58+
status=open, append `[reopen]` event с reason. Schema без
59+
изменений (статус уже строка).
60+
61+
## Order of work
62+
63+
1. Phase A (auto-create) — без неё B/C не используются
64+
2. Phase B (artifacts) — даёт сигнал для C
65+
3. Phase C (linked_issue) — последний
66+
67+
## Acceptance
68+
69+
После v0.5.0 юзер может **никогда** не вызывать `create` /
70+
`external` руками. Запускает Claude → работает → закрывает CLI →
71+
журнал имеет полную задачу с goal/events/artifacts.
72+
73+
Закрытие пока остаётся ручным (`close --outcome --outcome-tag`) —
74+
это явный акт "я закончил, выводы такие". Авто-close будет в
75+
v0.6.0 (heuristic: 7 дней без активности → suggest close).

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.4.1] - 2026-05-08
11+
12+
v0.5.0 Phase A — auto-create tasks. Removes the manual
13+
`task-journal create --goal "..."` step. The journal now opens a
14+
task on demand the first time a UserPromptSubmit fires into an
15+
empty project, taking the prompt itself as the goal. No prompt is
16+
ever lost again.
17+
18+
### Added
19+
- `auto_open_task_from_prompt()` helper in `tj-cli`. Synthesizes a
20+
task with `title = first line trimmed to 80 chars`,
21+
`goal = prompt trimmed to 200 chars`, then continues the normal
22+
classifier pipeline so the same prompt becomes the first event on
23+
the task it just opened.
24+
- `meta.auto_opened: true` flag on synthesized open events so
25+
reclassify / analytics can distinguish auto-opened tasks from
26+
user-created ones.
27+
28+
### Changed
29+
- `ingest-hook` previously dropped UserPromptSubmit events when no
30+
open task existed. Now it auto-opens unless the assistant tool
31+
call is the trigger (PostToolUse / Stop never conjure tasks).
32+
33+
### Configuration
34+
- `TJ_AUTO_OPEN_TASKS=0` (or `false`) restores the v0.4.0 silent-
35+
drop behaviour. Default is ON.
36+
37+
### Phase B/C still pending
38+
- B (artifacts auto-extract: commit_hash, pr_url, files, linked_issue)
39+
- C (linked_issue / reopen suggestion when prompt matches a recently
40+
closed task)
41+
1042
## [0.4.0] - 2026-05-08
1143

1244
Task model redesign — Phase 1. A task is now an explicit

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.4.0"
10+
version = "0.4.1"
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.4.0", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.4.1", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,10 +1396,27 @@ fn main() -> Result<()> {
13961396
if events_path.exists() {
13971397
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
13981398
}
1399-
let recent = recent_task_contexts(&conn, 5)?;
1399+
let mut recent = recent_task_contexts(&conn, 5)?;
14001400
if recent.is_empty() {
1401-
// No active tasks — nothing to classify against. Skip silently.
1402-
return Ok(());
1401+
// No open tasks. v0.5.0 Phase A: auto-open a new
1402+
// task from the user's prompt so subsequent
1403+
// events have somewhere to land. Without this
1404+
// every fresh session was a black hole — events
1405+
// dropped silently because there was nothing to
1406+
// classify against. Opt-out via
1407+
// TJ_AUTO_OPEN_TASKS=0; only fires for
1408+
// UserPromptSubmit (assistant tool calls
1409+
// shouldn't conjure tasks).
1410+
let auto_open_disabled = std::env::var("TJ_AUTO_OPEN_TASKS")
1411+
.ok()
1412+
.map(|v| v == "0" || v.eq_ignore_ascii_case("false"))
1413+
.unwrap_or(false);
1414+
if auto_open_disabled || !kind.contains("UserPrompt") {
1415+
return Ok(());
1416+
}
1417+
let new_task =
1418+
auto_open_task_from_prompt(&events_path, &project_hash, &conn, &text)?;
1419+
recent.push(new_task);
14031420
}
14041421

14051422
use tj_core::classifier::Classifier;
@@ -1943,6 +1960,55 @@ fn recent_task_contexts(
19431960
Ok(out)
19441961
}
19451962

1963+
/// v0.5.0 Phase A: when ingest-hook fires UserPromptSubmit and there
1964+
/// are no open tasks, synthesize one from the prompt itself. Title is
1965+
/// the first line trimmed to 80 chars; goal is the prompt trimmed to
1966+
/// 200 chars. Returns a TaskContext so the classifier has somewhere
1967+
/// to attach the same prompt as the first real event.
1968+
fn auto_open_task_from_prompt(
1969+
events_path: &std::path::Path,
1970+
project_hash: &str,
1971+
conn: &rusqlite::Connection,
1972+
prompt: &str,
1973+
) -> anyhow::Result<tj_core::classifier::TaskContext> {
1974+
let cleaned = prompt.trim();
1975+
// Title: first non-empty line, ≤80 chars. Falls back to "(empty
1976+
// prompt)" so we never write a NULL title — the classifier and
1977+
// the TUI both display titles directly.
1978+
let title: String = cleaned
1979+
.lines()
1980+
.map(|l| l.trim())
1981+
.find(|l| !l.is_empty())
1982+
.map(|l| l.chars().take(80).collect())
1983+
.unwrap_or_else(|| "(auto-opened: empty prompt)".to_string());
1984+
let goal: String = cleaned.chars().take(200).collect();
1985+
1986+
let task_id = tj_core::new_task_id();
1987+
let mut event = tj_core::event::Event::new(
1988+
task_id.clone(),
1989+
tj_core::event::EventType::Open,
1990+
tj_core::event::Author::User,
1991+
tj_core::event::Source::Cli,
1992+
title.clone(),
1993+
);
1994+
event.meta = serde_json::json!({ "title": title, "auto_opened": true });
1995+
1996+
let mut writer = tj_core::storage::JsonlWriter::open(events_path)?;
1997+
writer.append(&event)?;
1998+
writer.flush_durable()?;
1999+
2000+
tj_core::db::ingest_new_events(conn, events_path, project_hash)?;
2001+
if !goal.is_empty() {
2002+
tj_core::db::set_task_goal(conn, &task_id, &goal)?;
2003+
}
2004+
2005+
Ok(tj_core::classifier::TaskContext {
2006+
task_id,
2007+
title,
2008+
last_events: vec![],
2009+
})
2010+
}
2011+
19462012
fn persist_pending(events_path: &std::path::Path, text: &str, err: &str) -> anyhow::Result<()> {
19472013
let pending_dir = events_path
19482014
.parent()

crates/tj-cli/tests/cli.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,3 +1815,103 @@ fn help_lists_subcommands() {
18151815
.stdout(contains("events"))
18161816
.stdout(contains("rebuild-state"));
18171817
}
1818+
1819+
#[test]
1820+
fn ingest_hook_auto_opens_task_when_no_open_tasks() {
1821+
// v0.5.0 Phase A: a UserPromptSubmit hook firing into an empty
1822+
// project must synthesize a task on the fly, otherwise the prompt
1823+
// (and every event after it) is dropped silently.
1824+
let dir = assert_fs::TempDir::new().unwrap();
1825+
1826+
// Force the classifier to fail so the rest of the pipeline doesn't
1827+
// try to spawn `claude -p`. Auto-open happens BEFORE the classifier
1828+
// call, so the task should still be created.
1829+
let payload = serde_json::json!({
1830+
"hook_event_name": "UserPromptSubmit",
1831+
"session_id": "s-auto",
1832+
"transcript_path": "/tmp/x",
1833+
"cwd": "/tmp",
1834+
"prompt": "implement FIN-868 paygate fee dedup"
1835+
})
1836+
.to_string();
1837+
1838+
Command::cargo_bin("task-journal")
1839+
.unwrap()
1840+
.env("XDG_DATA_HOME", dir.path())
1841+
.env("TJ_CLASSIFIER_CLI", "/bin/false")
1842+
.args(["ingest-hook", "--backend", "cli"])
1843+
.write_stdin(payload)
1844+
.assert()
1845+
.success();
1846+
1847+
// Auto-opened task is now searchable. Pack it and check that the
1848+
// goal field equals the prompt text — that's the contract.
1849+
let search_out = Command::cargo_bin("task-journal")
1850+
.unwrap()
1851+
.env("XDG_DATA_HOME", dir.path())
1852+
.args(["search", "paygate"])
1853+
.assert()
1854+
.success()
1855+
.get_output()
1856+
.stdout
1857+
.clone();
1858+
let body = String::from_utf8(search_out).unwrap();
1859+
// Search output is task-id-per-line. A non-empty body proves the
1860+
// auto-opened task was indexed by FTS5 against the prompt text.
1861+
let task_id = body
1862+
.lines()
1863+
.next()
1864+
.map(|s| s.trim().to_string())
1865+
.filter(|s| s.starts_with("tj-"))
1866+
.unwrap_or_else(|| {
1867+
panic!("search must surface the auto-opened task by prompt text, got: {body:?}")
1868+
});
1869+
1870+
Command::cargo_bin("task-journal")
1871+
.unwrap()
1872+
.env("XDG_DATA_HOME", dir.path())
1873+
.args(["pack", &task_id, "--mode", "full"])
1874+
.assert()
1875+
.success()
1876+
.stdout(contains("**Goal**: implement FIN-868 paygate fee dedup"));
1877+
}
1878+
1879+
#[test]
1880+
fn ingest_hook_auto_open_disabled_via_env() {
1881+
// Opt-out path: TJ_AUTO_OPEN_TASKS=0 must restore the v0.4.0
1882+
// behaviour (drop the prompt silently when no open task exists).
1883+
let dir = assert_fs::TempDir::new().unwrap();
1884+
let payload = serde_json::json!({
1885+
"hook_event_name": "UserPromptSubmit",
1886+
"session_id": "s-noop",
1887+
"transcript_path": "/tmp/x",
1888+
"cwd": "/tmp",
1889+
"prompt": "marker_noautoopen_xyz must not appear"
1890+
})
1891+
.to_string();
1892+
1893+
Command::cargo_bin("task-journal")
1894+
.unwrap()
1895+
.env("XDG_DATA_HOME", dir.path())
1896+
.env("TJ_CLASSIFIER_CLI", "/bin/false")
1897+
.env("TJ_AUTO_OPEN_TASKS", "0")
1898+
.args(["ingest-hook", "--backend", "cli"])
1899+
.write_stdin(payload)
1900+
.assert()
1901+
.success();
1902+
1903+
let search_out = Command::cargo_bin("task-journal")
1904+
.unwrap()
1905+
.env("XDG_DATA_HOME", dir.path())
1906+
.args(["search", "marker_noautoopen_xyz"])
1907+
.assert()
1908+
.success()
1909+
.get_output()
1910+
.stdout
1911+
.clone();
1912+
let body = String::from_utf8(search_out).unwrap();
1913+
assert!(
1914+
!body.contains("marker_noautoopen_xyz"),
1915+
"auto-open must be skipped when TJ_AUTO_OPEN_TASKS=0, got: {body:?}"
1916+
);
1917+
}

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

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

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

0 commit comments

Comments
 (0)