Skip to content

Commit f50d211

Browse files
Shahinyanmclaude
andcommitted
feat(v0.10.2): watchPaths + FileChanged auto-evidence (X4)
Adopt the undocumented Claude Code 2.1.x watchPaths + FileChanged hook contract. SessionStart envelope now emits watchPaths for the project's marker files (CLAUDE.md, README.md, .docs/plans). When any of them changes, Claude Code fires FileChanged → ingest-hook appends an evidence event to the active task. FileChanged payload schema verified in 2.1.160: literal("FileChanged"), file_path: y.string(), event: y.enum(["change","add","unlink"]) - SessionStart envelope: watchPaths array of existing absolute paths. Missing files skipped (Claude Code logs watcher error and gives up on non-existent paths). - FileChanged branch in ingest-hook: appends evidence event with cwd-trimmed display path. No open task → silent no-op. - TJ_WATCH_PATHS=0 opts out of watchPaths emission. 4 tests: - session_start_emits_watch_paths_for_existing_marker_files - session_start_omits_watch_paths_when_disabled_via_env - file_changed_hook_appends_evidence_to_active_task - file_changed_hook_with_no_open_task_is_no_op Closes tj-aym (X4) — completes v0.10.x epic (tj-h7d). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3468068 commit f50d211

9 files changed

Lines changed: 341 additions & 8 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [0.10.2] - 2026-06-02
11+
12+
**`watchPaths` + FileChanged → auto-evidence on marker file edits.** X4
13+
of the v0.10.x undocumented-hooks adoption. SessionStart envelope now
14+
emits `watchPaths` — Claude Code starts monitoring the project's
15+
marker files (CLAUDE.md, README.md, .docs/plans/), and whenever one
16+
of them changes (write, create, delete), Claude Code fires the
17+
`FileChanged` hook event. Our `ingest-hook` handler translates that
18+
into an `evidence` event on the active task. Captures
19+
"instructions / plans were edited mid-session" without anyone manually
20+
typing it. Schema verified in Claude Code 2.1.160:
21+
`literal("FileChanged"), file_path: y.string(), event:
22+
y.enum(["change","add","unlink"])`.
23+
24+
### Added
25+
- SessionStart envelope emits `watchPaths` containing the absolute
26+
paths of `CLAUDE.md`, `README.md`, and `.docs/plans` when they
27+
exist under the current cwd. Missing files are skipped — Claude
28+
Code's watcher logs an error on non-existent paths, so we don't
29+
ask it to watch them.
30+
- `FileChanged` branch in the `ingest-hook` handler: appends an
31+
`evidence` event (`FileChanged (change|add|unlink): <relative path>`)
32+
to the most-recent open task. No active task → silently no-op.
33+
- 4 new integration tests:
34+
- `session_start_emits_watch_paths_for_existing_marker_files`
35+
- `session_start_omits_watch_paths_when_disabled_via_env`
36+
- `file_changed_hook_appends_evidence_to_active_task`
37+
- `file_changed_hook_with_no_open_task_is_no_op`
38+
39+
### Changed
40+
- Path display in FileChanged evidence trims the project cwd prefix
41+
so the journal stays project-relative and doesn't waste tokens on
42+
the absolute home path.
43+
44+
### Configuration
45+
- `TJ_WATCH_PATHS=0` suppresses watchPaths emission for users who
46+
don't want their marker-file edits auto-logged.
47+
48+
### Migration
49+
- None — additive on SessionStart envelope + new hook branch.
50+
Claude Code 2.1.x+ required for FileChanged event firing; older
51+
versions ignore unknown envelope keys and never emit FileChanged,
52+
so the handler simply never fires for them.
53+
1054
## [0.10.1] - 2026-06-02
1155

1256
**SessionStart envelope: `sessionTitle` + `initialUserMessage`.**

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

crates/tj-cli/src/main.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,6 +1540,33 @@ fn main() -> Result<()> {
15401540
bundle.push_str("\n\n");
15411541
}
15421542

1543+
// v0.10.2 X4: emit `watchPaths` so Claude Code starts
1544+
// monitoring our marker files (CLAUDE.md, README.md,
1545+
// .docs/plans). When any of them changes, Claude Code
1546+
// fires a FileChanged hook event — our ingest-hook
1547+
// handler below treats those as `evidence` entries on
1548+
// the active task so the journal captures
1549+
// "instructions were updated mid-session" without the
1550+
// user manually logging it. Only paths that exist at
1551+
// SessionStart time are emitted (no point watching a
1552+
// non-existent file — Claude Code logs `watcher error`
1553+
// and gives up on it). Gated by TJ_WATCH_PATHS=0.
1554+
let allow_watch_paths = std::env::var("TJ_WATCH_PATHS").as_deref() != Ok("0");
1555+
let watch_candidates = [
1556+
cwd.join("CLAUDE.md"),
1557+
cwd.join("README.md"),
1558+
cwd.join(".docs").join("plans"),
1559+
];
1560+
let watch_paths: Vec<String> = if allow_watch_paths {
1561+
watch_candidates
1562+
.iter()
1563+
.filter(|p| p.exists())
1564+
.map(|p| p.to_string_lossy().to_string())
1565+
.collect()
1566+
} else {
1567+
Vec::new()
1568+
};
1569+
15431570
// v0.10.1 X2: extend SessionStart envelope with the
15441571
// undocumented `sessionTitle` + `initialUserMessage`
15451572
// fields verified in Claude Code 2.1.160's K45 Zod
@@ -1569,6 +1596,14 @@ fn main() -> Result<()> {
15691596
recent.len(),
15701597
),
15711598
});
1599+
if !watch_paths.is_empty() {
1600+
hook_specific["watchPaths"] = serde_json::Value::Array(
1601+
watch_paths
1602+
.into_iter()
1603+
.map(serde_json::Value::String)
1604+
.collect(),
1605+
);
1606+
}
15721607
let allow_initial_user_msg =
15731608
std::env::var("TJ_INITIAL_USER_MESSAGE").as_deref() != Ok("0");
15741609
// `task-journal create` writes an [open] event, so
@@ -1590,6 +1625,64 @@ fn main() -> Result<()> {
15901625
return Ok(());
15911626
}
15921627

1628+
// v0.10.2 X4: FileChanged. Claude Code 2.1.x fires this
1629+
// event whenever a path in `watchPaths` (emitted on
1630+
// SessionStart) changes. Payload: { file_path, event:
1631+
// "change"|"add"|"unlink" }. We translate it into an
1632+
// `evidence` event on the active task — captures
1633+
// "the user/agent edited CLAUDE.md mid-session" without
1634+
// anyone typing anything. Schema verified in 2.1.160:
1635+
// `literal("FileChanged"), file_path: y.string(), event:
1636+
// y.enum(["change","add","unlink"])`.
1637+
//
1638+
// No active task → drop silently (we're not opening a
1639+
// task just because a watched file moved). No events_path
1640+
// → ditto, fresh project.
1641+
if kind == "FileChanged" {
1642+
if !events_path.exists() {
1643+
return Ok(());
1644+
}
1645+
let state_path =
1646+
tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite"));
1647+
let conn = tj_core::db::open(&state_path)?;
1648+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
1649+
let recent = recent_task_contexts(&conn, 1)?;
1650+
let Some(tc) = recent.into_iter().next() else {
1651+
return Ok(());
1652+
};
1653+
let file_path = payload
1654+
.get("file_path")
1655+
.and_then(|v| v.as_str())
1656+
.unwrap_or("(unknown)");
1657+
let change = payload
1658+
.get("event")
1659+
.and_then(|v| v.as_str())
1660+
.unwrap_or("change");
1661+
// Trim noisy absolute paths to project-relative when
1662+
// possible — the journal is per-project so the prefix
1663+
// is redundant and just steals tokens from the pack.
1664+
let display_path = cwd
1665+
.to_str()
1666+
.and_then(|c| file_path.strip_prefix(c))
1667+
.map(|s| s.trim_start_matches('/').to_string())
1668+
.unwrap_or_else(|| file_path.to_string());
1669+
let evidence_text = format!("FileChanged ({change}): {display_path}");
1670+
let mut event = tj_core::event::Event::new(
1671+
&tc.task_id,
1672+
tj_core::event::EventType::Evidence,
1673+
tj_core::event::Author::Classifier,
1674+
tj_core::event::Source::Hook,
1675+
evidence_text,
1676+
);
1677+
event.confidence = Some(0.9);
1678+
event.status = tj_core::event::EventStatus::Confirmed;
1679+
let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?;
1680+
writer.append(&event)?;
1681+
writer.flush_durable()?;
1682+
println!("{}", event.event_id);
1683+
return Ok(());
1684+
}
1685+
15931686
// PreCompact: Claude Code is about to compact the conversation.
15941687
// Two responsibilities:
15951688
// 1. Catch-up ingest — read the transcript JSONL tail (entries

0 commit comments

Comments
 (0)