Skip to content

Commit 9a4a5ce

Browse files
Shahinyanmclaude
andcommitted
feat(v0.10.0)!: PostToolUse asyncRewake — zero-latency happy path, wake on backlog
Adopt the undocumented Claude Code 2.1.x asyncRewake hook field (verified in 2.1.160's Zod schema: asyncRewake + rewakeMessage + rewakeSummary). PostToolUse hook now runs entirely in background; model never blocks on `task-journal ingest-hook` on success path. When pending/ grows past PENDING_OVERFLOW_THRESHOLD (25), the hook exits 2 — Claude Code interprets as "wake model with system reminder". User sees rewakeSummary "Task Journal backlog forming" plus hook stdout pointing at `task-journal pending-gc --days 0`. Classifier- behind state surfaces before queue grows to hundreds. Wake-signal gated on TJ_ASYNC_REWAKE=1 env var, set ONLY by the PostToolUse hook command in hooks.json. CLI invocations + sync PreCompact/Stop hooks never exit 2 even on overflow — exit 2 from a sync hook would BLOCK the operation in Claude Code's contract. Closes tj-857 (X1) under tj-h7d (v0.10.x epic). Plan tj-813 (X2), tj-x3t (X3), tj-aym (X4) tracked separately. Migration: `task-journal install-hooks --uninstall && task-journal install-hooks` to pick up new hook contract. Claude Code 2.1.x+ required for asyncRewake recognition (older versions silently fall back to synchronous behavior). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 4982519 commit 9a4a5ce

10 files changed

Lines changed: 244 additions & 9 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.10.0] - 2026-06-02
11+
12+
**`asyncRewake` on PostToolUse — zero-latency happy path, wake on backlog.**
13+
Adopts the undocumented `asyncRewake: true` Claude Code hook field (verified
14+
present in 2.1.160's Zod schema as `asyncRewake` + `rewakeMessage` +
15+
`rewakeSummary`). The PostToolUse hook now runs entirely in the background:
16+
the model never blocks on `task-journal ingest-hook` on the success path,
17+
even though the binary still does its full persist-to-`pending/` + spawn-
18+
classify-worker work. When the pending queue grows past 25 entries, the
19+
hook exits with code 2, which Claude Code interprets as "wake the model
20+
with a system reminder." The model sees `rewakeSummary` ("Task Journal
21+
backlog forming") plus the hook's stdout — a one-liner pointing at
22+
`task-journal pending-gc --days 0`. The classifier-can't-keep-up state
23+
becomes visible BEFORE it grows into the hundreds (the v0.6.2 fork-bomb
24+
era saw 515 entries before a user noticed).
25+
26+
PreCompact and Stop hooks stay synchronous — they do transcript catch-up
27+
that must finish before compaction/exit, and exit code 2 from a sync
28+
hook BLOCKS the operation in Claude Code's contract. The wake-signal is
29+
gated on `TJ_ASYNC_REWAKE=1`, which only the PostToolUse hook command
30+
sets; CLI invocations and sync hooks never exit 2 even on overflow.
31+
32+
### Added
33+
- `PENDING_OVERFLOW_THRESHOLD = 25` const and `count_pending_entries`
34+
helper in `tj-cli` — best-effort directory count, IO errors return 0
35+
so a borked filesystem never wakes the model with noise.
36+
- 3 new integration tests: `asyncrewake_below_threshold_exits_zero`,
37+
`asyncrewake_overflow_exits_two_with_drain_hint`,
38+
`asyncrewake_overflow_without_env_does_not_exit_two` — the last one
39+
is the sync-hook safety guarantee.
40+
41+
### Changed
42+
- `plugin/hooks/hooks.json` PostToolUse entry now declares
43+
`"asyncRewake": true` + `"rewakeSummary": "Task Journal backlog forming"`
44+
and the command sets `TJ_ASYNC_REWAKE=1`. Dropped the trailing
45+
`|| true` from the PostToolUse command — asyncRewake hooks treat
46+
exit codes themselves; other exit codes are ignored, only code 2
47+
wakes. PreCompact and Stop entries are unchanged.
48+
49+
### Migration
50+
- `task-journal install-hooks --uninstall && task-journal install-hooks`
51+
to pick up the new hook contract. Claude Code 2.1.x or later required
52+
for the `asyncRewake` field to be recognized (silently ignored on
53+
older versions — they will run the PostToolUse hook synchronously
54+
as a fallback). The 25-entry threshold is hard-coded for v0.10.0;
55+
later releases may make it configurable.
56+
1057
## [0.9.4] - 2026-05-17
1158

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

crates/tj-cli/src/main.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,25 @@ fn main() -> Result<()> {
17751775
// a failure to spawn just means the entry sits in
17761776
// pending/ until the next hook fires another spawn.
17771777
let _ = spawn_classify_worker(&backend);
1778+
1779+
// v0.10.0 asyncRewake backlog signal. Only the PostToolUse
1780+
// hook runs as asyncRewake (hooks.json sets TJ_ASYNC_REWAKE=1
1781+
// there), so other kinds — and direct CLI invocations —
1782+
// never exit 2 even on overflow. Exit code 2 from a sync
1783+
// hook would BLOCK the operation; only asyncRewake hooks
1784+
// treat code 2 as "wake the model with rewakeMessage". stdout
1785+
// is appended to the wake message, so the user sees the
1786+
// drain command without us reaching into stderr.
1787+
let allow_wake = std::env::var("TJ_ASYNC_REWAKE").as_deref() == Ok("1");
1788+
if allow_wake && kind == "PostToolUse" {
1789+
let pending_count = count_pending_entries(&events_path).unwrap_or(0);
1790+
if pending_count > PENDING_OVERFLOW_THRESHOLD {
1791+
println!(
1792+
"Task Journal pending queue: {pending_count} entries. Classifier behind — run `task-journal pending-gc --days 0` to drain.",
1793+
);
1794+
std::process::exit(2);
1795+
}
1796+
}
17781797
return Ok(());
17791798
}
17801799

@@ -2844,6 +2863,41 @@ fn persist_pending(events_path: &std::path::Path, text: &str, err: &str) -> anyh
28442863
/// v0.6.2: queue an ingest event for the detached classify-worker. The
28452864
/// hook returns immediately after writing this entry so it does not
28462865
/// block Claude Code's hook timeout (was 5-30s, now <100ms). Schema "v2"
2866+
/// Threshold for the v0.10.0 asyncRewake backlog signal. When the
2867+
/// PostToolUse hook (configured with `asyncRewake: true` in
2868+
/// `hooks.json`) finds more than this many entries already queued
2869+
/// in `pending/`, it exits with code 2 to wake the model with a
2870+
/// system reminder pointing at `task-journal pending-gc`. Tuned so
2871+
/// that normal load (<5 in-flight at any moment) never trips, but
2872+
/// a stuck classifier surfaces visibly before the queue grows into
2873+
/// the hundreds (the v0.6.2 fork-bomb era saw 515 entries before a
2874+
/// user noticed).
2875+
const PENDING_OVERFLOW_THRESHOLD: usize = 25;
2876+
2877+
/// Count `.json` (and `.json.dead`) entries currently sitting in
2878+
/// `pending/` next to `events_path`. Best-effort: any IO error
2879+
/// returns 0 so a borked filesystem never wakes the model with
2880+
/// noise. Used by the asyncRewake backlog signal.
2881+
fn count_pending_entries(events_path: &std::path::Path) -> anyhow::Result<usize> {
2882+
let dir = events_path
2883+
.parent()
2884+
.and_then(|p| p.parent())
2885+
.ok_or_else(|| anyhow::anyhow!("events_path has no grandparent"))?
2886+
.join("pending");
2887+
if !dir.exists() {
2888+
return Ok(0);
2889+
}
2890+
let mut count = 0usize;
2891+
for entry in std::fs::read_dir(&dir)? {
2892+
let entry = entry?;
2893+
let path = entry.path();
2894+
if let Some("json") = path.extension().and_then(|e| e.to_str()) {
2895+
count += 1;
2896+
}
2897+
}
2898+
Ok(count)
2899+
}
2900+
28472901
/// distinguishes async-ingest entries from legacy v1 (text+error) ones
28482902
/// the `pending retry` path knows how to handle.
28492903
fn persist_pending_v2(

crates/tj-cli/tests/cli.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2966,3 +2966,135 @@ fn export_pr_unknown_task_id_exits_one_with_stderr_message() {
29662966
.failure()
29672967
.stderr(contains("task not found: tj-zzzz"));
29682968
}
2969+
2970+
// v0.10.0: asyncRewake backlog signal. PostToolUse hook configured with
2971+
// asyncRewake:true in hooks.json sets TJ_ASYNC_REWAKE=1; when pending/
2972+
// has more than PENDING_OVERFLOW_THRESHOLD (25) entries already queued,
2973+
// ingest-hook exits 2 with a wake-message on stdout. Sync hooks (or
2974+
// CLI invocations without the env var) must NEVER exit 2 — that would
2975+
// block the operation in Claude Code's hook contract.
2976+
fn seed_pending_chunks(pending_dir: &std::path::Path, count: usize) {
2977+
std::fs::create_dir_all(pending_dir).unwrap();
2978+
for i in 0..count {
2979+
let payload = serde_json::json!({
2980+
"schema": "v2",
2981+
"kind": "PostToolUse",
2982+
"text": format!("seed-{i}"),
2983+
"project_hash": "deadbeefdeadbeef",
2984+
"events_path": "/tmp/unused.jsonl",
2985+
"backend": "hybrid",
2986+
"queued_at": "2099-01-01T00:00:00Z",
2987+
});
2988+
std::fs::write(
2989+
pending_dir.join(format!("seed-{i:04}.json")),
2990+
serde_json::to_string_pretty(&payload).unwrap(),
2991+
)
2992+
.unwrap();
2993+
}
2994+
}
2995+
2996+
fn posttooluse_payload() -> String {
2997+
serde_json::json!({
2998+
"hook_event_name": "PostToolUse",
2999+
"tool_name": "Bash",
3000+
"tool_input": {"command": "echo hi"},
3001+
"tool_response": {"stdout": "hi"},
3002+
})
3003+
.to_string()
3004+
}
3005+
3006+
#[test]
3007+
fn asyncrewake_below_threshold_exits_zero() {
3008+
let dir = assert_fs::TempDir::new().unwrap();
3009+
let workdir = dir.path().join("proj");
3010+
std::fs::create_dir_all(&workdir).unwrap();
3011+
3012+
Command::cargo_bin("task-journal")
3013+
.unwrap()
3014+
.env("XDG_DATA_HOME", dir.path())
3015+
.current_dir(&workdir)
3016+
.args(["create", "Async wake test below threshold"])
3017+
.assert()
3018+
.success();
3019+
3020+
// Seed 5 entries — well under the 25 threshold.
3021+
let pending = dir.path().join("task-journal").join("pending");
3022+
seed_pending_chunks(&pending, 5);
3023+
3024+
Command::cargo_bin("task-journal")
3025+
.unwrap()
3026+
.env("XDG_DATA_HOME", dir.path())
3027+
.env("TJ_ASYNC_REWAKE", "1")
3028+
.env("TJ_DISABLE_CLASSIFY_SPAWN", "1")
3029+
.current_dir(&workdir)
3030+
.args(["ingest-hook", "--backend", "hybrid"])
3031+
.write_stdin(posttooluse_payload())
3032+
.assert()
3033+
.success(); // exit 0, no wake
3034+
}
3035+
3036+
#[test]
3037+
fn asyncrewake_overflow_exits_two_with_drain_hint() {
3038+
let dir = assert_fs::TempDir::new().unwrap();
3039+
let workdir = dir.path().join("proj");
3040+
std::fs::create_dir_all(&workdir).unwrap();
3041+
3042+
Command::cargo_bin("task-journal")
3043+
.unwrap()
3044+
.env("XDG_DATA_HOME", dir.path())
3045+
.current_dir(&workdir)
3046+
.args(["create", "Async wake overflow test"])
3047+
.assert()
3048+
.success();
3049+
3050+
// Seed 30 entries — over the 25 threshold.
3051+
let pending = dir.path().join("task-journal").join("pending");
3052+
seed_pending_chunks(&pending, 30);
3053+
3054+
Command::cargo_bin("task-journal")
3055+
.unwrap()
3056+
.env("XDG_DATA_HOME", dir.path())
3057+
.env("TJ_ASYNC_REWAKE", "1")
3058+
.env("TJ_DISABLE_CLASSIFY_SPAWN", "1")
3059+
.current_dir(&workdir)
3060+
.args(["ingest-hook", "--backend", "hybrid"])
3061+
.write_stdin(posttooluse_payload())
3062+
.assert()
3063+
.failure()
3064+
.code(2)
3065+
.stdout(contains("Task Journal pending queue"))
3066+
.stdout(contains("pending-gc"));
3067+
}
3068+
3069+
#[test]
3070+
fn asyncrewake_overflow_without_env_does_not_exit_two() {
3071+
// Sync hook safety: without TJ_ASYNC_REWAKE=1 we must NEVER exit 2
3072+
// even on overflow, because exit 2 from a sync hook blocks the
3073+
// operation in Claude Code. CLI invocations and the PreCompact/Stop
3074+
// hooks (which stay sync) rely on this guarantee.
3075+
let dir = assert_fs::TempDir::new().unwrap();
3076+
let workdir = dir.path().join("proj");
3077+
std::fs::create_dir_all(&workdir).unwrap();
3078+
3079+
Command::cargo_bin("task-journal")
3080+
.unwrap()
3081+
.env("XDG_DATA_HOME", dir.path())
3082+
.current_dir(&workdir)
3083+
.args(["create", "Sync hook safety test"])
3084+
.assert()
3085+
.success();
3086+
3087+
let pending = dir.path().join("task-journal").join("pending");
3088+
seed_pending_chunks(&pending, 30);
3089+
3090+
Command::cargo_bin("task-journal")
3091+
.unwrap()
3092+
.env("XDG_DATA_HOME", dir.path())
3093+
.env_remove("TJ_ASYNC_REWAKE")
3094+
.env("TJ_DISABLE_CLASSIFY_SPAWN", "1")
3095+
.current_dir(&workdir)
3096+
.args(["ingest-hook", "--backend", "hybrid"])
3097+
.write_stdin(posttooluse_payload())
3098+
.assert()
3099+
.success(); // exit 0, no wake — must not block sync hooks
3100+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"hooks": [
77
{
88
"type": "command",
9-
"command": "task-journal ingest-hook 2>/dev/null || true"
9+
"command": "TJ_ASYNC_REWAKE=1 task-journal ingest-hook 2>/dev/null",
10+
"asyncRewake": true,
11+
"rewakeSummary": "Task Journal backlog forming"
1012
}
1113
]
1214
}

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.9.4",
3+
"version": "0.10.0",
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)