Merge remote-tracking branch 'pr/1005' into resolution/release-2.6

# Conflicts:
#	CHANGELOG.md
#	crates/ai-memory-cli/src/commands/doctor.rs
This commit is contained in:
AkitaOnRails
2026-10-01 12:54:09 -03:00
5 changed files with 404 additions and 4 deletions
+6 -1
View File
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added `ai-memory doctor` reporting for Claude Code's default native
`memory/` store for the current repository: location, file count, and whether
the nearest marker's `ignore_paths` would exclude a read. Repository-root
resolution keeps worktrees and subdirectories on the same report, and the
output warns that shell/PowerShell compatibility hooks do not enforce the
exclusion. (#1003)
- Added per-event outcomes to `/hook/batch` acknowledgements and process-lifetime
ingest counters for stored events, replays, recovery, ignored endings,
collisions and failures. Legacy acknowledgement fields were preserved. (#1015)
@@ -44,7 +50,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Documented FutureInfra as an endpoint for the existing `openai-compat`
provider. (#1026)
### Fixed
- Fixed completed retries and no-op session endings advancing
`last_persisted_ms` without a durable write. Recovery still advances the
+251 -1
View File
@@ -27,7 +27,10 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use ai_memory_core::AgentKind;
use ai_memory_workstream::{ManagedHarness, build_launch_plan, list_native_sessions};
use ai_memory_hooks::CaptureDisposition;
use ai_memory_workstream::{
ManagedHarness, build_launch_plan, list_native_sessions, native_memory_dir,
};
use crate::config::Config;
use crate::http_client::{ServerEndpoint, get_json};
@@ -146,6 +149,11 @@ struct DoctorReport {
marker_capture_problem: Option<MarkerCaptureProblem>,
capture_owner_active: bool,
identity: Option<OperatorIdentity>,
/// Native "memory" stores found for harnesses that keep one, and whether
/// the nearest marker would exclude a read of them (harness-issue
/// #1003). Only Claude Code's location is known today; harnesses with no
/// established convention are simply absent from this list.
native_memory: Vec<NativeMemoryReport>,
}
#[derive(Debug, Clone, Serialize)]
@@ -154,6 +162,20 @@ struct MarkerCaptureProblem {
reason: String,
}
#[derive(Debug, Clone, Serialize)]
struct NativeMemoryReport {
agent: String,
path: String,
file_count: usize,
/// One of `"excluded"`, `"not excluded"`, `"marker invalid"`, or
/// `"metadata only"` (an active, valid policy whose extraction for this
/// specific probe fell back to the metadata-only disposition — kept
/// distinct from `"not excluded"` rather than folded into it, since the
/// two mean different things: the dispositions are mapped one-to-one,
/// never collapsed).
status: String,
}
/// Fold the raw per-harness local scans and the server's captured counts into
/// one row per agent kind. Pure: no IO, so the verdict logic is unit-tested
/// directly. Rows for agent kinds that neither ran locally nor captured
@@ -225,6 +247,82 @@ pub(crate) fn relocated_session_dir(harness: ManagedHarness) -> Option<PathBuf>
.and_then(|plan| plan.session_dir)
}
/// Harnesses whose native "memory" store location is known, so `doctor` can
/// report on it (harness-issue #1003). Only Claude Code's is established
/// today — `~/.claude/projects/<project>/memory/`, found via
/// [`native_memory_dir`]'s content-based match rather than a guessed,
/// platform-specific directory name. A harness left out of this list simply
/// produces no report line, which is the documented behavior for "location
/// unknown" rather than an error.
const HARNESSES_WITH_KNOWN_NATIVE_MEMORY: &[ManagedHarness] = &[ManagedHarness::Claude];
/// For every harness in [`HARNESSES_WITH_KNOWN_NATIVE_MEMORY`] that actually
/// has a memory store for this checkout, report its location, how many
/// files it holds, and whether the nearest marker would exclude a read of
/// it.
///
/// `cwd` is resolved to the repository root first (worktrees and
/// subdirectories collapse onto it): Claude Code keys its auto-memory by
/// repository root, so [`native_memory_dir`] -- which only matches a session
/// recorded for the exact cwd it is given -- would otherwise find nothing
/// when `doctor` runs from a subdirectory or a linked worktree.
///
/// The exclusion check reuses two already-verified read-only facts rather
/// than re-deriving anything: [`super::hook_capture::capture_config_problem`]
/// (a broken marker is reported as `"marker invalid"`, the same diagnosis
/// `doctor`'s other check makes) and, when the marker parses and compiles,
/// [`ai_memory_hooks::CapturePolicy::inspect`] on a synthetic read of one
/// file inside the store — the exact evaluation the native hook performs on
/// a real `Read` tool call, just never sent anywhere. Every
/// `CaptureDisposition` maps to its own distinct status string; none are
/// folded together.
fn native_memory_reports(home: &Path, cwd: &Path) -> Vec<NativeMemoryReport> {
let repo_root =
ai_memory_consolidate::discover_main_repo_root(cwd).unwrap_or_else(|_| cwd.to_path_buf());
let Some(repo_root_str) = repo_root.to_str() else {
return Vec::new();
};
let mut reports = Vec::new();
for &harness in HARNESSES_WITH_KNOWN_NATIVE_MEMORY {
let session_dir = relocated_session_dir(harness);
let Some(memory_dir) = native_memory_dir(harness, home, &repo_root, session_dir.as_deref())
else {
continue;
};
let file_count = std::fs::read_dir(&memory_dir)
.map(|entries| {
entries
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|ft| ft.is_file()))
.count()
})
.unwrap_or(0);
let status = if super::hook_capture::capture_config_problem(repo_root_str).is_some() {
"marker invalid"
} else {
let policy = super::hook_capture::capture_policy(repo_root_str);
let probe_path = memory_dir.join("probe.md");
let raw = serde_json::json!({
"tool_name": "Read",
"tool_input": { "file_path": probe_path.to_string_lossy() },
});
let decision = policy.inspect(harness.agent_kind(), &raw, repo_root_str);
match decision.protocol().disposition() {
CaptureDisposition::Drop => "excluded",
CaptureDisposition::Keep => "not excluded",
CaptureDisposition::MetadataOnly => "metadata only",
}
};
reports.push(NativeMemoryReport {
agent: harness.agent_kind().as_str().to_owned(),
path: memory_dir.display().to_string(),
file_count,
status: status.to_owned(),
});
}
reports
}
/// [`scan_local`] with the relocation lookup passed in. The lookup reads the
/// process environment, so a test that plants a fixture under a temporary
/// `$HOME` passes `|_| None`: otherwise a developer's `CLAUDE_CONFIG_DIR`
@@ -344,6 +442,8 @@ pub async fn run(config: &Config, args: crate::cli::DoctorArgs) -> Result<()> {
})
});
let native_memory = native_memory_reports(&home, &cwd);
let report = DoctorReport {
workspace,
project,
@@ -354,6 +454,7 @@ pub async fn run(config: &Config, args: crate::cli::DoctorArgs) -> Result<()> {
marker_capture_problem,
capture_owner_active: config.runtime_env.capture_owner_active(),
identity,
native_memory,
};
if args.json {
@@ -401,6 +502,15 @@ fn render_human(report: &DoctorReport) {
);
}
if !report.native_memory.is_empty() {
println!(
"Note: a shell/PowerShell hook install (the Docker-wrapper default) does not \
enforce capture-policy v1 — \"excluded\" below only holds on a native hook \
install or a generated integration. Check the configured hook command; a dry-run \
`ai-memory install-hooks` reports the selected install path, not the active one.\n"
);
}
if report.rows.is_empty() {
println!(" No local harness sessions found for this project and nothing captured yet.");
return;
@@ -431,6 +541,15 @@ fn render_human(report: &DoctorReport) {
Backfill can also mix source metadata; this does not prove duplicate capture."
);
}
if let Some(memory) = report.native_memory.iter().find(|m| m.agent == row.agent) {
println!(
" native memory: {} ({} file{})",
memory.path,
memory.file_count,
if memory.file_count == 1 { "" } else { "s" }
);
println!(" capture: {}", memory.status);
}
}
if report.uncaptured.is_empty() {
@@ -659,4 +778,135 @@ mod tests {
let none = scan_local_with(empty_home.path(), cwd.path(), 0, |_| None).await;
assert!(none.is_empty(), "no stores should mean no scans: {none:?}");
}
/// Plants a Claude Code session (for `cwd`) with a sibling `memory/`
/// directory under `home`, the fixture every `native_memory_reports`
/// test below starts from.
fn claude_memory_fixture(home: &Path, cwd: &Path) -> PathBuf {
let project_dir = home.join(".claude/projects/fixture");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join("session.jsonl"),
format!(
"{}\n",
serde_json::json!({
"sessionId": "11111111-2222-3333-4444-555555555555",
"cwd": cwd.to_string_lossy(),
})
),
)
.unwrap();
let memory_dir = project_dir.join("memory");
std::fs::create_dir_all(&memory_dir).unwrap();
memory_dir
}
fn find_claude_report(reports: &[NativeMemoryReport]) -> &NativeMemoryReport {
reports
.iter()
.find(|r| r.agent == "claude-code")
.unwrap_or_else(|| panic!("expected a claude-code report; got {reports:?}"))
}
#[test]
fn native_memory_reports_is_not_excluded_without_a_marker() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let memory_dir = claude_memory_fixture(home.path(), cwd.path());
std::fs::write(memory_dir.join("fact.md"), "x").unwrap();
let reports = native_memory_reports(home.path(), cwd.path());
let claude = find_claude_report(&reports);
assert_eq!(claude.status, "not excluded");
assert_eq!(claude.file_count, 1);
}
#[test]
fn native_memory_reports_is_excluded_when_the_marker_covers_it() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let memory_dir = claude_memory_fixture(home.path(), cwd.path());
std::fs::write(memory_dir.join("fact.md"), "x").unwrap();
// An absolute pattern naming the fixture's own (fake) home, so this
// does not depend on the real process `$HOME` the way a `~/...`
// pattern would inside capture_policy's own home resolution.
std::fs::write(
cwd.path().join(".ai-memory.toml"),
format!(
"[capture]\nignore_paths = [{:?}]\n",
format!("{}/.claude/projects/**", home.path().display())
),
)
.unwrap();
let reports = native_memory_reports(home.path(), cwd.path());
let claude = find_claude_report(&reports);
assert_eq!(claude.status, "excluded");
}
#[test]
fn native_memory_reports_is_marker_invalid_for_a_broken_capture_table() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
claude_memory_fixture(home.path(), cwd.path());
// The exact dropped-`#` shape found in practice: a stray token right
// after a `[capture]` header.
std::fs::write(
cwd.path().join(".ai-memory.toml"),
"[capture] this used to be a comment\nignore_paths = [\"**\"]\n",
)
.unwrap();
let reports = native_memory_reports(home.path(), cwd.path());
let claude = find_claude_report(&reports);
assert_eq!(claude.status, "marker invalid");
}
#[test]
fn native_memory_reports_file_count_ignores_subdirectories() {
let home = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let memory_dir = claude_memory_fixture(home.path(), cwd.path());
std::fs::write(memory_dir.join("fact.md"), "x").unwrap();
std::fs::create_dir_all(memory_dir.join("a-subdirectory")).unwrap();
std::fs::write(memory_dir.join("a-subdirectory/nested.md"), "y").unwrap();
let reports = native_memory_reports(home.path(), cwd.path());
let claude = find_claude_report(&reports);
assert_eq!(
claude.file_count, 1,
"the subdirectory itself must not be counted as a file"
);
}
/// The bug found in review: Claude Code keys its auto-memory by
/// repository root, so running `doctor` from a subdirectory (or a linked
/// worktree) must still resolve the root's `memory/` store, not come up
/// empty because the session was recorded for the root and not the
/// subdirectory `doctor` happened to run from.
#[test]
fn native_memory_reports_resolves_a_subdirectory_to_the_repository_root() {
let home = tempfile::tempdir().unwrap();
let repo = tempfile::tempdir().unwrap();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.arg(repo.path())
.status();
if !matches!(status, Ok(s) if s.success()) {
// No git binary in this environment: the repo-root resolution
// falls back to the given cwd unchanged, so there is nothing
// this test can distinguish here. Skip rather than fail.
return;
}
let subdir = repo.path().join("sub/dir");
std::fs::create_dir_all(&subdir).unwrap();
// The session was recorded for the repository ROOT, as Claude Code
// itself would record it -- not for the subdirectory.
let memory_dir = claude_memory_fixture(home.path(), repo.path());
std::fs::write(memory_dir.join("fact.md"), "x").unwrap();
let reports = native_memory_reports(home.path(), &subdir);
let claude = find_claude_report(&reports);
assert_eq!(claude.file_count, 1);
}
}
+1 -1
View File
@@ -24,6 +24,6 @@ pub use repository::{RepositoryIdentity, inspect_repository};
pub use transcript::{
AmbiguousNativeSession, ExportedTranscript, NativeSessionCandidate, discover_native_session,
export_transcript, kiro_harness_from_source_cursor, kiro_v3_resume_uses_default_store,
list_native_sessions, native_session_exists, native_session_in_checkout,
list_native_sessions, native_memory_dir, native_session_exists, native_session_in_checkout,
wait_for_transcript_flush,
};
@@ -266,6 +266,60 @@ pub async fn list_native_sessions(
Ok(sessions)
}
/// Resolve the on-disk directory holding this harness's native "memory"
/// store for the given checkout, when the harness has one and a session
/// recorded for this cwd can be found.
///
/// Claude Code keeps a `memory/` directory as a sibling of its own session
/// transcripts, at `<home>/.claude/projects/<project-dir>/memory/` — the
/// native store `ai-memory doctor` warns about capturing unless a marker's
/// `ignore_paths` excludes it (harness-issue #1003). `<project-dir>`'s name
/// is Claude Code's own encoding of the cwd, which is **not** the same
/// encoding on every platform (native Windows also folds `\` and `:`, not
/// only `/`), so this never re-derives that name. Instead it reuses the same
/// content-based match [`list_native_sessions`] uses — read each session
/// file's own recorded `cwd` until one matches — and returns that file's
/// parent directory, which is correct on any platform by construction.
///
/// Callers should pass the checkout's **repository root**, not an arbitrary
/// subdirectory: Claude Code keys this store by repository root, so a
/// worktree or a subdirectory shares the root's `memory/` directory. This
/// function itself does no such resolution -- it matches whatever `cwd` it
/// is given against recorded session cwds -- so passing a subdirectory here
/// finds nothing even when a `memory/` store genuinely exists for the
/// repository.
///
/// Harnesses with no known native-memory convention return `None`
/// unconditionally; callers should treat that as "nothing to report", not as
/// an error.
#[must_use]
pub fn native_memory_dir(
harness: ManagedHarness,
home: &Path,
cwd: &Path,
session_dir: Option<&Path>,
) -> Option<PathBuf> {
if harness != ManagedHarness::Claude {
return None;
}
let mut files = collect_session_files(harness, home, session_dir).ok()?;
files.sort_by(|left, right| {
modified(right)
.cmp(&modified(left))
.then_with(|| left.cmp(right))
});
for path in files {
let Ok(Some((_, recorded_cwd))) = session_header_for_cwd(harness, &path, cwd) else {
continue;
};
if same_path(&recorded_cwd, cwd) {
let memory_dir = path.parent()?.join("memory");
return memory_dir.is_dir().then_some(memory_dir);
}
}
None
}
/// Whether the native store holds `native_session_id` for this checkout.
/// [`native_session_exists`] answers for the store; OpenCode keeps every
/// checkout's sessions in one database, so there the recorded directory must
@@ -5938,4 +5992,89 @@ mod tests {
// A ten-byte varint may carry only one payload bit in its final byte.
assert_eq!(protobuf_varint(&[0xff; 10]), None);
}
#[test]
fn native_memory_dir_finds_the_sibling_memory_directory_for_claude() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
// Claude Code's own project-directory name is irrelevant here -- the
// whole point of this function is to find it by content, never by
// guessing the encoding (which differs between POSIX and native
// Windows).
let project_dir = home.join(".claude/projects/some-encoded-name");
fs::create_dir_all(&project_dir).unwrap();
fs::write(
project_dir.join("session-1.jsonl"),
format!("{}\n", json!({"sessionId": "abc", "cwd": cwd})),
)
.unwrap();
let memory_dir = project_dir.join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(memory_dir.join("fact.md"), "hello").unwrap();
assert_eq!(
native_memory_dir(ManagedHarness::Claude, &home, &cwd, None),
Some(memory_dir)
);
}
#[test]
fn native_memory_dir_is_none_without_a_memory_subdirectory() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let project_dir = home.join(".claude/projects/some-encoded-name");
fs::create_dir_all(&project_dir).unwrap();
fs::write(
project_dir.join("session-1.jsonl"),
format!("{}\n", json!({"sessionId": "abc", "cwd": cwd})),
)
.unwrap();
// A real session was found, but it never created a memory/ store.
assert_eq!(
native_memory_dir(ManagedHarness::Claude, &home, &cwd, None),
None
);
}
#[test]
fn native_memory_dir_is_none_when_no_session_matches_this_cwd() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let cwd = temp.path().join("repo");
let other = temp.path().join("other");
fs::create_dir_all(&cwd).unwrap();
let project_dir = home.join(".claude/projects/some-encoded-name");
fs::create_dir_all(&project_dir).unwrap();
fs::write(
project_dir.join("session-1.jsonl"),
format!("{}\n", json!({"sessionId": "abc", "cwd": other})),
)
.unwrap();
fs::create_dir_all(project_dir.join("memory")).unwrap();
assert_eq!(
native_memory_dir(ManagedHarness::Claude, &home, &cwd, None),
None
);
}
#[test]
fn native_memory_dir_is_none_for_harnesses_with_no_known_convention() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
// Codex has no established native-memory convention today, so this
// must return None unconditionally rather than guess at a location.
assert_eq!(
native_memory_dir(ManagedHarness::Codex, &home, &cwd, None),
None
);
}
}
+7 -1
View File
@@ -320,7 +320,13 @@ ai-memory serve # run the server
every harness that has local sessions in this project and whether the server
captured them — so a harness you rotated in without installing its hook (a
silent gap: it keeps its own local history while capturing nothing) shows up
as a warning with the exact `install-hooks` command to fix it.
as a warning with the exact `install-hooks` command to fix it. For Claude
Code it also reports the detected default auto-memory directory and whether
the repository's capture exclusions cover it. A custom
`autoMemoryDirectory` is not discoverable from Claude's session transcripts
and is not reported. An `excluded` verdict applies only to native/generated
hooks; shell and PowerShell compatibility hooks do not enforce capture-policy
exclusions.
- **I just installed hooks in a project I've worked in for a while**: the first
time you open the project after installing, ai-memory imports your existing
local session history once (bounded, sanitized on the server, only into an