Skip to content

Commit 8b4602e

Browse files
Shahinyanmclaude
andcommitted
fix(hook): read Claude Code payload from stdin (v0.2.8)
Critical: prior versions interpolated $CLAUDE_HOOK_NAME and $CLAUDE_HOOK_TEXT in the install-hooks command — Claude Code never sets those env vars. Production hooks always called the classifier with empty text, every event piled up in pending/, and the journal never recorded anything beyond what manual `task-journal event` produced. Fix: - IngestHook.kind / .text become Option<String>. When omitted, the command reads stdin as JSON (the actual hook payload) and projects it to a (kind, text) pair: UserPromptSubmit -> prompt; PreToolUse / PostToolUse -> "{tool_name}: {tool_input}[ -> {tool_response}]"; Stop / SessionStart -> empty. - install-hooks now writes `task-journal ingest-hook --backend=cli || true` — no env-var interpolation. The `|| true` safety net stays. - Empty stdin returns ("Stop", "") instead of erroring, so dry-run invocations and missing pipes don't crash. 3 new integration tests cover UserPromptSubmit prompt extraction, PostToolUse tool_name+input+response synthesis, and the install- hooks command no longer containing the bogus env vars. CLI overrides (--kind / --text) still work for the legacy unit/integration tests. Closes claude-memory-rsw. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e46288b commit 8b4602e

10 files changed

Lines changed: 258 additions & 14 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.2.7"
9+
"version": "0.2.8"
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.2.7",
16+
"version": "0.2.8",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.2.8] - 2026-05-07
11+
12+
Critical fix: hooks now actually carry content end-to-end. Without
13+
this release, every captured event reached the classifier with empty
14+
text, queued in `pending/`, and never got classified.
15+
16+
### Fixed
17+
- `ingest-hook` now reads the Claude Code hook payload as JSON from
18+
stdin (the documented wiring) instead of relying on `$CLAUDE_HOOK_NAME`
19+
/ `$CLAUDE_HOOK_TEXT` env vars that Claude Code never set. Per
20+
hook kind:
21+
- `UserPromptSubmit``prompt`
22+
- `PreToolUse` / `PostToolUse` → synthesized from `tool_name`,
23+
`tool_input`, and (when present) `tool_response`
24+
- `Stop` / `SessionStart` → empty (SessionStart already short-
25+
circuits to its resume-pack path).
26+
`--kind` / `--text` remain accepted as CLI overrides for tests and
27+
ad-hoc use; they take precedence when both are passed.
28+
- `install-hooks` now writes `task-journal ingest-hook --backend=cli
29+
|| true` — the bogus env-var interpolation is gone. Closes
30+
claude-memory-rsw.
31+
1032
## [0.2.7] - 2026-05-07
1133

1234
### Fixed

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

crates/tj-cli/src/main.rs

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -725,13 +725,20 @@ enum Commands {
725725
force: bool,
726726
},
727727
/// Hook entry point: ingest a chat chunk through the classifier.
728+
///
729+
/// When `--kind` and `--text` are both omitted, reads the Claude Code
730+
/// hook payload as JSON from stdin (the actual production wiring).
731+
/// `--kind` / `--text` remain for tests and ad-hoc use.
728732
IngestHook {
729733
/// Hook kind: UserPromptSubmit | PostToolUse | Stop | SessionStart.
734+
/// If omitted, derived from stdin JSON (`hook_event_name`).
730735
#[arg(long)]
731-
kind: String,
732-
/// The chat chunk text.
736+
kind: Option<String>,
737+
/// The chat chunk text. If omitted, derived from stdin JSON
738+
/// (`prompt` for UserPromptSubmit, synthesized from
739+
/// tool_name+input+response for PostToolUse, etc.).
733740
#[arg(long)]
734-
text: String,
741+
text: Option<String>,
735742
/// Classifier backend: "cli" uses `claude -p` (free with your Pro/Max
736743
/// subscription) or "api" uses Anthropic API (requires `ANTHROPIC_API_KEY`).
737744
/// Default: cli.
@@ -1046,7 +1053,12 @@ fn main() -> Result<()> {
10461053
// and replay on next ingest.
10471054
// Default to subscription-based classifier (`claude -p`).
10481055
// Power users with API key can run install-hooks --backend=api below.
1049-
let cmd = "task-journal ingest-hook --kind=$CLAUDE_HOOK_NAME --text=\"$CLAUDE_HOOK_TEXT\" --backend=cli || true";
1056+
// Claude Code pipes the hook payload as JSON on stdin; the
1057+
// `--kind` / `--text` flags from earlier templates pointed
1058+
// at env vars Claude Code never sets and therefore always
1059+
// fed the classifier empty text. Stdin-only is the correct
1060+
// wiring (see claude-memory-rsw).
1061+
let cmd = "task-journal ingest-hook --backend=cli || true";
10501062
let entries = serde_json::json!({
10511063
"UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
10521064
"PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
@@ -1174,6 +1186,17 @@ fn main() -> Result<()> {
11741186
mock_task_id,
11751187
mock_confidence,
11761188
} => {
1189+
// Resolve (kind, text) source: explicit args win; otherwise
1190+
// read the Claude Code hook payload from stdin. The earlier
1191+
// settings.json template interpolated `$CLAUDE_HOOK_NAME` /
1192+
// `$CLAUDE_HOOK_TEXT` env vars that Claude Code does NOT set,
1193+
// so production was always called with empty text and every
1194+
// event ended up rejected — see claude-memory-rsw.
1195+
let (kind, text) = match (kind, text) {
1196+
(Some(k), Some(t)) => (k, t),
1197+
_ => parse_hook_stdin()?,
1198+
};
1199+
11771200
let cwd = std::env::current_dir()?;
11781201
let project_hash = tj_core::project_hash::from_path(&cwd)?;
11791202
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
@@ -1811,6 +1834,70 @@ fn drain_pending(
18111834
Ok(())
18121835
}
18131836

1837+
/// Read a Claude Code hook payload from stdin and project it down to
1838+
/// the (kind, text) pair the rest of `ingest-hook` operates on.
1839+
///
1840+
/// Claude Code passes hook input as a JSON object on stdin. The fields
1841+
/// we care about (per the public hooks spec):
1842+
///
1843+
/// - common: `hook_event_name`
1844+
/// - UserPromptSubmit: `prompt`
1845+
/// - PreToolUse / PostToolUse: `tool_name`, `tool_input`, `tool_response`
1846+
/// - Stop / SessionStart: nothing extra worth ingesting (SessionStart
1847+
/// takes a separate fast path further up)
1848+
///
1849+
/// If stdin is empty (someone runs the command interactively without
1850+
/// piping), we silently return ("Stop", "") so the hook becomes a no-op
1851+
/// instead of erroring — matches the `|| true` safety net in the
1852+
/// installed hook command.
1853+
fn parse_hook_stdin() -> anyhow::Result<(String, String)> {
1854+
let mut buf = String::new();
1855+
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)
1856+
.context("read hook payload from stdin")?;
1857+
let buf = buf.trim();
1858+
if buf.is_empty() {
1859+
return Ok(("Stop".into(), String::new()));
1860+
}
1861+
let v: serde_json::Value =
1862+
serde_json::from_str(buf).with_context(|| format!("parse hook payload JSON: {buf}"))?;
1863+
1864+
let kind = v
1865+
.get("hook_event_name")
1866+
.and_then(|s| s.as_str())
1867+
.unwrap_or("Stop")
1868+
.to_string();
1869+
1870+
let text = match kind.as_str() {
1871+
"UserPromptSubmit" => v
1872+
.get("prompt")
1873+
.and_then(|s| s.as_str())
1874+
.unwrap_or("")
1875+
.to_string(),
1876+
"PreToolUse" | "PostToolUse" => {
1877+
let tool = v
1878+
.get("tool_name")
1879+
.and_then(|s| s.as_str())
1880+
.unwrap_or("tool");
1881+
let input = v
1882+
.get("tool_input")
1883+
.map(|x| x.to_string())
1884+
.unwrap_or_default();
1885+
let response = v
1886+
.get("tool_response")
1887+
.map(|x| x.to_string())
1888+
.unwrap_or_default();
1889+
if response.is_empty() {
1890+
format!("{tool}: {input}")
1891+
} else {
1892+
format!("{tool}: {input} → {response}")
1893+
}
1894+
}
1895+
_ => String::new(),
1896+
};
1897+
1898+
Ok((kind, text))
1899+
}
1900+
18141901
fn parse_event_type(s: &str) -> anyhow::Result<tj_core::event::EventType> {
18151902
use tj_core::event::EventType::*;
18161903
Ok(match s {

crates/tj-cli/tests/cli.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,141 @@ fn ingest_hook_session_start_with_no_open_tasks_emits_no_context() {
12361236
);
12371237
}
12381238

1239+
#[test]
1240+
fn ingest_hook_reads_user_prompt_submit_payload_from_stdin() {
1241+
// Real Claude Code passes hook input as JSON over stdin, NOT via env
1242+
// vars. Without this, every captured event has empty text and the
1243+
// classifier rejects it. Regression for claude-memory-rsw.
1244+
let dir = assert_fs::TempDir::new().unwrap();
1245+
let task_id = String::from_utf8(
1246+
Command::cargo_bin("task-journal")
1247+
.unwrap()
1248+
.env("XDG_DATA_HOME", dir.path())
1249+
.args(["create", "Stdin host"])
1250+
.assert()
1251+
.success()
1252+
.get_output()
1253+
.stdout
1254+
.clone(),
1255+
)
1256+
.unwrap()
1257+
.trim()
1258+
.to_string();
1259+
1260+
let payload = serde_json::json!({
1261+
"hook_event_name": "UserPromptSubmit",
1262+
"session_id": "s-1",
1263+
"transcript_path": "/tmp/x",
1264+
"cwd": "/tmp",
1265+
"prompt": "We adopted Rust for the journal."
1266+
})
1267+
.to_string();
1268+
1269+
Command::cargo_bin("task-journal")
1270+
.unwrap()
1271+
.env("XDG_DATA_HOME", dir.path())
1272+
.args([
1273+
"ingest-hook",
1274+
"--backend",
1275+
"cli",
1276+
"--mock-event-type",
1277+
"decision",
1278+
"--mock-task-id",
1279+
&task_id,
1280+
"--mock-confidence",
1281+
"0.95",
1282+
])
1283+
.write_stdin(payload)
1284+
.assert()
1285+
.success();
1286+
1287+
Command::cargo_bin("task-journal")
1288+
.unwrap()
1289+
.env("XDG_DATA_HOME", dir.path())
1290+
.args(["pack", &task_id, "--mode", "full"])
1291+
.assert()
1292+
.success()
1293+
.stdout(contains("We adopted Rust for the journal"));
1294+
}
1295+
1296+
#[test]
1297+
fn ingest_hook_reads_post_tool_use_payload_from_stdin() {
1298+
// PostToolUse payloads have no `prompt` field — content lives in
1299+
// `tool_name` / `tool_input` / `tool_response`. The stdin parser must
1300+
// synthesize text from those.
1301+
let dir = assert_fs::TempDir::new().unwrap();
1302+
let task_id = String::from_utf8(
1303+
Command::cargo_bin("task-journal")
1304+
.unwrap()
1305+
.env("XDG_DATA_HOME", dir.path())
1306+
.args(["create", "Tool host"])
1307+
.assert()
1308+
.success()
1309+
.get_output()
1310+
.stdout
1311+
.clone(),
1312+
)
1313+
.unwrap()
1314+
.trim()
1315+
.to_string();
1316+
1317+
let payload = serde_json::json!({
1318+
"hook_event_name": "PostToolUse",
1319+
"session_id": "s-2",
1320+
"transcript_path": "/tmp/x",
1321+
"cwd": "/tmp",
1322+
"tool_name": "Bash",
1323+
"tool_input": { "command": "cargo test" },
1324+
"tool_response": { "output": "all 222 tests pass" }
1325+
})
1326+
.to_string();
1327+
1328+
Command::cargo_bin("task-journal")
1329+
.unwrap()
1330+
.env("XDG_DATA_HOME", dir.path())
1331+
.args([
1332+
"ingest-hook",
1333+
"--backend",
1334+
"cli",
1335+
"--mock-event-type",
1336+
"evidence",
1337+
"--mock-task-id",
1338+
&task_id,
1339+
"--mock-confidence",
1340+
"0.9",
1341+
])
1342+
.write_stdin(payload)
1343+
.assert()
1344+
.success();
1345+
1346+
Command::cargo_bin("task-journal")
1347+
.unwrap()
1348+
.env("XDG_DATA_HOME", dir.path())
1349+
.args(["pack", &task_id, "--mode", "full"])
1350+
.assert()
1351+
.success()
1352+
.stdout(contains("Bash").and(contains("cargo test")));
1353+
}
1354+
1355+
#[test]
1356+
fn install_hooks_writes_command_without_bogus_env_var_interpolation() {
1357+
// The old install-hooks emitted $CLAUDE_HOOK_NAME / $CLAUDE_HOOK_TEXT,
1358+
// neither of which Claude Code actually populates. The current command
1359+
// must rely on stdin instead.
1360+
let dir = assert_fs::TempDir::new().unwrap();
1361+
Command::cargo_bin("task-journal")
1362+
.unwrap()
1363+
.env("HOME", dir.path())
1364+
.args(["install-hooks", "--scope", "user"])
1365+
.assert()
1366+
.success();
1367+
let s = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
1368+
assert!(
1369+
!s.contains("$CLAUDE_HOOK_NAME") && !s.contains("$CLAUDE_HOOK_TEXT"),
1370+
"install-hooks must not interpolate non-existent env vars: {s}"
1371+
);
1372+
}
1373+
12391374
#[test]
12401375
fn ingest_hook_with_mock_writes_classified_event() {
12411376
let dir = assert_fs::TempDir::new().unwrap();

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.2.7", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.8", 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.2.7",
3+
"version": "0.2.8",
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"

plugin/package.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.2.7",
3+
"version": "0.2.8",
44
"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.",
55
"author": {
66
"name": "Mher Shahinyan",

0 commit comments

Comments
 (0)