fix: load Claw and Agents memory files

Generated with https://github.com/Yeachan-Heo/gajae-code

Co-authored-by: Gajae Code <dev@gajae-code.com>
This commit is contained in:
bellman
2026-06-04 16:36:04 +09:00
parent ae7da0ec74
commit 5b22bc0480
7 changed files with 372 additions and 26 deletions

View File

@@ -6389,7 +6389,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
437. **DONE — `version --output-format json` exposes complete build provenance without duplicating prose** — fixed 2026-06-04 in `fix: expose complete version provenance`. Build metadata now records the full 40-character `git_sha`, separate derived `git_sha_short`, `is_dirty`, `branch`, ISO-8601 `commit_date`, Unix `commit_timestamp`, `rustc_version`, target, and build date. Version JSON exposes those fields at top level and mirrors them under `binary_provenance`; `workspace_git_sha` is also a full SHA and `workspace_match` now compares full commit identities. `executable_path` is resolved at runtime with `std::env::current_exe()` instead of reporting a compile-host path. The prose report is no longer duplicated in JSON as `message`; JSON callers get the secondary text block as `human_readable`. Docs in `USAGE.md` and `rust/README.md` describe the provenance contract. Regression coverage: `version_emits_json_when_requested`, `version_status_doctor_include_binary_provenance_797`, `resumed_version_and_init_emit_structured_json_when_requested`, and `resumed_version_command_emits_structured_json`.
438. **Memory file discovery only recognizes `CLAUDE.md` — `AGENTS.md` (industry convention used by OpenCode/Codex/Aider/Cursor) and `CLAW.md` (project's own brand name) are silently ignored despite being present in the workspace**dogfooded 2026-05-11 by Jobdori on `d3a982dd` in response to Clawhip pinpoint nudge at `1503328341422244012`. Reproduction (fresh empty dir, isolated `CLAW_CONFIG_HOME`): create three files in cwd — `CLAUDE.md` (marker `MARKER-FROM-CLAUDE-MD`), `AGENTS.md` (marker `MARKER-FROM-AGENTS-MD`), `CLAW.md` (marker `MARKER-FROM-CLAW-MD`). Run `claw status --output-format json` `workspace.memory_file_count: 1`. Run `claw system-prompt --output-format json` and search the `message` field for each marker: only `MARKER-FROM-CLAUDE-MD` is found; `MARKER-FROM-AGENTS-MD` and `MARKER-FROM-CLAW-MD` are absent. `claw-code` exclusively recognizes the Claude-branded filename inherited from upstream Claude Code; the project's own `CLAW.md` brand name and the cross-tool industry convention `AGENTS.md` are both silently dropped. **Three sibling implications:** (a) **brand-consistency gap**: a project rebranded from Claude Code to Claw Code that introduces `CLAUDE.md` as its only memory file is internally inconsistent. Users naturally expect `claw <subcommand>` to read `CLAW.md`. (b) **industry-convention gap**: `AGENTS.md` is the convergent convention for OpenCode (oh-my-opencode/sisyphus), OpenAI Codex CLI, Aider, Cursor, Continue.dev, and most ACP harnesses. Users with mixed-tool workflows maintain a shared `AGENTS.md` and expect every AI coding tool to honor it. (c) **silent failure mode**: there is no warning when `AGENTS.md` or `CLAW.md` exist but are not loaded. Users who copy-paste `AGENTS.md` from another tool's docs see `memory_file_count` stay at 0 or 1 and have to guess why their instructions aren't applied. **Required fix shape:** (a) discover and load **`CLAUDE.md`, `CLAW.md`, `AGENTS.md`** in that priority order (existing config-precedence pattern); (b) all three contribute to `memory_file_count` with `memory_files:[{path, source:"claude_md"|"claw_md"|"agents_md", chars}]` array exposed in `status --output-format json`; (c) when multiple files exist, merge or document the precedence: project-specific `CLAUDE.md`/`CLAW.md` overrides industry-shared `AGENTS.md`; (d) `claw doctor --output-format json` adds a `memory` check that warns when `AGENTS.md` exists but is not the loaded variant (alerting users that they may be relying on the wrong file); (e) regression test: workspace with all three files results in `memory_file_count >= 1` and the system prompt contains markers from at least the highest-precedence file. **Why this matters:** `AGENTS.md` is the lingua-franca instruction file for cross-tool AI coding workflows. A team using OpenCode for one project and Claw Code for another keeps their conventions in a shared `AGENTS.md`. Forcing them to also maintain a `CLAUDE.md` for claw-code (with identical content) is friction that breaks the value proposition of a fork. Cross-references #438 itself (the multi-file convention), and AGENTS.md ecosystem references in oh-my-opencode/sisyphus docs. Source: Jobdori live dogfood, `d3a982dd`, 2026-05-11.
438. **DONE — memory discovery loads `CLAUDE.md`, `CLAW.md`, and `AGENTS.md` with structured provenance**fixed 2026-06-04 in `fix: load Claw and Agents memory files`. Project memory discovery now checks root instruction files in `CLAUDE.md`, `CLAW.md`, then `AGENTS.md` order for each discovered directory, preserves existing scoped `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, `.claw/instructions.md`, and rules-directory imports, and exposes each loaded file's `path`, `source`, `chars`, and `contributes` in `status --output-format json` as `workspace.memory_files[]`. `system-prompt --output-format json` returns the same memory metadata alongside the rendered `message`/`sections`, and all non-duplicate loaded files contribute to the prompt so CLAUDE/CLAW/AGENTS markers are visible together. `claw doctor --output-format json` now includes a dedicated `memory` check with loaded memory metadata and `unloaded_memory_files[]` warnings for present `CLAW.md`/`AGENTS.md` candidates that were skipped (for example empty or duplicate-content variants). Docs in `USAGE.md` and `rust/README.md` describe the priority and JSON contracts. Regression coverage: `discovers_claude_claw_agents_and_dot_claude_instruction_files_together`, `memory_files_load_claude_claw_agents_and_surface_json_438`, and `memory_health_surfaces_loaded_and_unloaded_files_438`.
439. **Memory file discovery walks ALL ancestor directories up to `$HOME` boundary, silently loading any `CLAUDE.md` it finds — `/tmp/CLAUDE.md` left from a previous test silently bleeds into every project under `/tmp/*/`; no `--no-parent-memory` flag, no `.no-claude-md-boundary` marker file to limit discovery scope** — dogfooded 2026-05-11 by Jobdori on `f4a96740` in response to Clawhip pinpoint nudge at `1503335892461293675`. Reproduction: create three nested `CLAUDE.md` files with unique markers — `/tmp/claw-nested-probe/CLAUDE.md` (`PARENT_CLAUDE`), `subproj/CLAUDE.md` (`CHILD_CLAUDE`), `subproj/deep/CLAUDE.md` (`DEEP_CLAUDE`). Run `claw system-prompt --output-format json` from `subproj/deep/nest/` (note: `nest` has no `CLAUDE.md`). The `message` field contains **all three markers** (PARENT + CHILD + DEEP) and `status --output-format json` reports `memory_file_count: 3`. Boundary tests: (a) `$HOME/CLAUDE.md` is NOT picked up from `/tmp/no-claude-dir` (discovery stops at `$HOME` boundary, good); (b) From `/tmp/deep` (no nested CLAUDE.md), `/tmp/CLAUDE.md` IS picked up (count: 1); (c) git-root is NOT a discovery boundary — running from a git subdir still walks above the git root. **Ambient-context-bleed footgun:** any stale `/tmp/CLAUDE.md` (or `/home/<user>/projects/CLAUDE.md`, or any ancestor-path CLAUDE.md left over from a previous experiment, copy-paste, or AI-generated example) silently bleeds into every workspace nested below it. The user has no signal in `status --output-format json` indicating which ancestor file is contributing — only the aggregate `memory_file_count`. **Three required fixes:** (a) **expose discovery list**: `status --output-format json` and `system-prompt --output-format json` must include `memory_files:[{path, source:"workspace"|"ancestor"|"parent_dir"|"home", chars, contributes:bool}]` so users can see what's leaking in; (b) **add `--no-parent-memory` flag** to limit discovery to cwd only (no ancestor walk), or add a boundary marker (`.claude-no-walk`, `.claw-root`, or honor `.git` as the boundary by default — most users expect repo-root scope); (c) **`doctor` warns** when ancestor `CLAUDE.md` files are loaded from outside the current git repo (suggests they may be unintentional). **Sibling discovery scope question:** discovery walks up to `$HOME` — but for a user with a project at `/Users/foo/work/proj`, that's `/Users/foo/work/CLAUDE.md` + `/Users/foo/CLAUDE.md` (if it exists) both load. The home boundary is exclusive, but the entire `/Users/foo` tree under home is in scope. **Why this matters:** test workspaces, scratch dirs, AI-generated example projects, and shared `/tmp` workdirs are full of stale `CLAUDE.md` files. The current discovery rule means every claw invocation can silently inherit context from arbitrary ancestor paths. Cross-references #438 (memory discovery only finds CLAUDE.md, not AGENTS.md or CLAW.md), #421 (cwd canonicalization leak — the canonicalized form determines which ancestor walk path is used). Source: Jobdori live dogfood, `f4a96740`, 2026-05-11.

View File

@@ -51,7 +51,7 @@ cd rust
```
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`.
`version --output-format json` reports structured build provenance including full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; JSON keeps the prose report in `human_readable` instead of duplicating it under `message`. `status --output-format json` exposes `workspace.memory_files[]` with `path`, `source`, `chars`, and `contributes` for every loaded project memory file.
### Initialize a repository
@@ -594,11 +594,13 @@ Object-style matchers are optional. When present, they match tool names case-ins
## Project instruction rules
In addition to root instruction files such as `CLAUDE.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
In addition to root instruction files such as `CLAUDE.md`, `CLAW.md`, `AGENTS.md`, `.claw/CLAUDE.md`, `.claude/CLAUDE.md`, and `.claw/instructions.md`, `claw` loads sorted Markdown/text rule files from:
- `<repo>/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules.
- `<repo>/.claw/rules.local/` for personal local rules; this path is gitignored.
Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md` for each discovered directory. All loaded files contribute to the system prompt and to `status --output-format json` as `workspace.memory_files:[{path, source, chars, contributes}]`; `claw doctor --output-format json` includes a `memory` check so automation can detect loaded and unexpected unloaded memory-file candidates without parsing prompt text.
By default, `claw` also imports detected rules from common AI coding tools such as Cursor (`.cursorrules`, `.cursor/rules/`), GitHub Copilot (`.github/copilot-instructions.md`), Windsurf, Plandex, and Crush. Control this with `rulesImport` in any settings file:
```json

View File

@@ -87,7 +87,7 @@ Primary artifacts:
| Sub-agent / agent surfaces | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ |
| CLAUDE.md / CLAW.md / AGENTS.md project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ |
@@ -149,6 +149,7 @@ Top-level commands:
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon or JSON-RPC entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands. Status queries exit 0 and expose the same machine-readable contract via `--output-format json`; malformed ACP invocations exit 1 with `kind: unsupported_acp_invocation`.
`--output-format` accepts `text` or `json` in any casing. `CLAW_OUTPUT_FORMAT=json` selects JSON as the default for non-interactive commands, explicit flags override it, repeated flags warn on stderr, and status JSON exposes `format_source`, `format_raw`, and `format_overridden`. Help and doctor output also surface `CLAW_LOG` / `RUST_LOG` as the logging environment knobs.
`claw version --output-format json` is the provenance probe for automation: it reports full `git_sha`, derived `git_sha_short`, `is_dirty`, `branch`, `commit_date`, `commit_timestamp`, `rustc_version`, runtime `executable_path`, and `binary_provenance`; the text report is available as `human_readable` instead of a duplicate `message` field.
`status --output-format json` reports loaded project memory files under `workspace.memory_files[]` with each file's `path`, `source` (`claude_md`, `claw_md`, `agents_md`, or scoped/rule sources), `chars`, and `contributes`; `claw doctor --output-format json` includes a dedicated `memory` check. Root instruction-file priority is `CLAUDE.md`, then `CLAW.md`, then `AGENTS.md`, and all non-duplicate loaded files contribute to the rendered system prompt.
Shorthand prompt mode honors the POSIX `--` end-of-flags separator, so `claw -- "-prompt-with-dash"` and unknown dash-prefixed non-flag text stay on the prompt path instead of being treated as CLI options.
`claw dump-manifests` is self-contained: it emits the Rust resolver inventory for the selected workspace (commands, tools, agents, skills, and bootstrap phases) without requiring an upstream Claude Code TypeScript checkout. Use `--manifests-dir PATH` only to scope resolver discovery to another directory.

View File

@@ -142,8 +142,9 @@ pub use policy_engine::{
PolicyEvaluation, PolicyRule, ReconcileReason, ReviewStatus,
};
pub use prompt::{
load_system_prompt, prepend_bullets, ContextFile, ModelFamilyIdentity, ProjectContext,
PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
load_system_prompt, load_system_prompt_with_context, prepend_bullets, ContextFile,
ModelFamilyIdentity, ProjectContext, PromptBuildError, SystemPromptBuilder,
FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
};
pub use recovery_recipes::{
attempt_recovery, recipe_for, EscalationPolicy, FailureScenario, RecoveryAttemptState,

View File

@@ -69,6 +69,18 @@ pub struct ContextFile {
pub content: String,
}
impl ContextFile {
#[must_use]
pub fn source(&self) -> &'static str {
instruction_file_source(&self.path)
}
#[must_use]
pub fn char_count(&self) -> usize {
self.content.chars().count()
}
}
/// Project-local context injected into the rendered system prompt.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectContext {
@@ -256,6 +268,24 @@ pub fn prepend_bullets(items: Vec<String>) -> Vec<String> {
items.into_iter().map(|item| format!(" - {item}")).collect()
}
fn instruction_file_source(path: &Path) -> &'static str {
let file_name = path.file_name().and_then(|name| name.to_str());
let parent_name = path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str());
match (parent_name, file_name) {
(Some(".claw"), Some("CLAUDE.md")) => "claw_claude_md",
(Some(".claude"), Some("CLAUDE.md")) => "claude_claude_md",
(_, Some("CLAUDE.md")) => "claude_md",
(_, Some("CLAW.md")) => "claw_md",
(_, Some("AGENTS.md")) => "agents_md",
(_, Some("CLAUDE.local.md")) => "claude_local_md",
(Some(".claw"), Some("instructions.md")) => "claw_instructions",
_ => "rule_file",
}
}
fn discover_instruction_files(
cwd: &Path,
rules_import: &RulesImportConfig,
@@ -272,6 +302,7 @@ fn discover_instruction_files(
for dir in directories {
for candidate in [
dir.join("CLAUDE.md"),
dir.join("CLAW.md"),
dir.join("AGENTS.md"),
dir.join("CLAUDE.local.md"),
dir.join(".claw").join("CLAUDE.md"),
@@ -430,7 +461,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
];
if !project_context.instruction_files.is_empty() {
bullets.push(format!(
"Claude instruction files discovered: {}.",
"Project instruction files discovered: {}.",
project_context.instruction_files.len()
));
}
@@ -465,7 +496,7 @@ fn render_project_context(project_context: &ProjectContext) -> String {
}
fn render_instruction_files(files: &[ContextFile]) -> String {
let mut sections = vec!["# Claude instructions".to_string()];
let mut sections = vec!["# Project instructions".to_string()];
let mut remaining_chars = MAX_TOTAL_INSTRUCTION_CHARS;
for file in files {
if remaining_chars == 0 {
@@ -573,16 +604,31 @@ pub fn load_system_prompt(
os_version: impl Into<String>,
model_family: ModelFamilyIdentity,
) -> Result<Vec<String>, PromptBuildError> {
let cwd = cwd.into();
let (sections, _) =
load_system_prompt_with_context(cwd, current_date, os_name, os_version, model_family)?;
Ok(sections)
}
/// Loads config and project context, then renders the system prompt text plus metadata.
pub fn load_system_prompt_with_context(
cwd: impl Into<PathBuf>,
current_date: impl Into<String>,
os_name: impl Into<String>,
os_version: impl Into<String>,
model_family: ModelFamilyIdentity,
) -> Result<(Vec<String>, ProjectContext), PromptBuildError> {
let cwd = cwd.into();
let config = ConfigLoader::default_for(&cwd).load()?;
let project_context =
discover_with_git_and_rules_import(&cwd, current_date.into(), config.rules_import())?;
Ok(SystemPromptBuilder::new()
let sections = SystemPromptBuilder::new()
.with_os(os_name, os_version)
.with_model_family(model_family)
.with_project_context(project_context)
.with_project_context(project_context.clone())
.with_runtime_config(config)
.build())
.build();
Ok((sections, project_context))
}
fn render_config_section(config: &RuntimeConfig) -> String {
@@ -844,10 +890,11 @@ mod tests {
}
#[test]
fn discovers_claude_agents_and_dot_claude_instruction_files_together() {
fn discovers_claude_claw_agents_and_dot_claude_instruction_files_together() {
let root = temp_dir();
fs::create_dir_all(root.join(".claude")).expect("dot claude dir");
fs::write(root.join("CLAUDE.md"), "claude instructions").expect("write CLAUDE.md");
fs::write(root.join("CLAW.md"), "claw instructions").expect("write CLAW.md");
fs::write(root.join("AGENTS.md"), "agents instructions").expect("write AGENTS.md");
fs::write(
root.join(".claude").join("CLAUDE.md"),
@@ -857,8 +904,18 @@ mod tests {
let context = ProjectContext::discover(&root, "2026-03-31").expect("context should load");
let rendered = render_instruction_files(&context.instruction_files);
let sources = context
.instruction_files
.iter()
.map(ContextFile::source)
.collect::<Vec<_>>();
assert_eq!(
sources,
vec!["claude_md", "claw_md", "agents_md", "claude_claude_md"]
);
assert!(rendered.contains("claude instructions"));
assert!(rendered.contains("claw instructions"));
assert!(rendered.contains("agents instructions"));
assert!(rendered.contains("dot claude instructions"));
fs::remove_dir_all(root).expect("cleanup temp dir");
@@ -1218,7 +1275,7 @@ mod tests {
assert!(prompt.contains("# System"));
assert!(prompt.contains("# Project context"));
assert!(prompt.contains("# Claude instructions"));
assert!(prompt.contains("# Project instructions"));
assert!(prompt.contains("Project rules"));
assert!(prompt.contains("permissionMode"));
assert!(prompt.contains(SYSTEM_PROMPT_DYNAMIC_BOUNDARY));
@@ -1263,7 +1320,7 @@ mod tests {
path: PathBuf::from("/tmp/project/CLAUDE.md"),
content: "Project rules".to_string(),
}]);
assert!(rendered.contains("# Claude instructions"));
assert!(rendered.contains("# Project instructions"));
assert!(rendered.contains("scope: /tmp/project"));
assert!(rendered.contains("Project rules"));
}

View File

@@ -53,12 +53,13 @@ use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status,
ApiClient, ApiRequest, AssistantEvent, BaseCommitState, CompactionConfig, ConfigFileReport,
ConfigLoader, ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServer,
McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
load_system_prompt, load_system_prompt_with_context, pricing_for_model, resolve_expected_base,
resolve_sandbox_status, ApiClient, ApiRequest, AssistantEvent, BaseCommitState,
CompactionConfig, ConfigFileReport, ConfigLoader, ConfigSource, ContentBlock, ContextFile,
ConversationMessage, ConversationRuntime, McpServer, McpServerManager, McpServerSpec, McpTool,
MessageRole, ModelPricing, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent,
ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
UsageTracker,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
@@ -3501,6 +3502,11 @@ fn render_doctor_report(
.map_or(0, |runtime_config| runtime_config.loaded_entries().len()),
discovered_config_files: discovered_config.len(),
memory_file_count: project_context.instruction_files.len(),
memory_files: memory_file_summaries(&project_context.instruction_files),
unloaded_memory_files: unloaded_memory_candidates(
&cwd,
&memory_file_summaries(&project_context.instruction_files),
),
project_root,
git_branch,
git_summary,
@@ -3521,6 +3527,7 @@ fn render_doctor_report(
check_config_health(&config_loader, config.as_ref()),
check_install_source_health(),
check_workspace_health(&context),
check_memory_health(&context),
check_boot_preflight_health(&context),
check_sandbox_health(&context.sandbox_status),
check_permission_health(permission_mode),
@@ -3975,6 +3982,19 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
"Memory files {} · config files loaded {}/{}",
context.memory_file_count, context.loaded_config_files, context.discovered_config_files
),
format!(
"Loaded memory {}",
if context.memory_files.is_empty() {
"<none>".to_string()
} else {
context
.memory_files
.iter()
.map(|file| format!("{}:{}", file.source, file.path))
.collect::<Vec<_>>()
.join(", ")
}
),
format!(
"Stale base {}",
stale_base_warning.as_deref().unwrap_or("ok")
@@ -4003,6 +4023,14 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
"memory_file_count".to_string(),
json!(context.memory_file_count),
),
(
"memory_files".to_string(),
Value::Array(memory_files_json(&context.memory_files)),
),
(
"unloaded_memory_files".to_string(),
json!(context.unloaded_memory_files),
),
(
"loaded_config_files".to_string(),
json!(context.loaded_config_files),
@@ -4018,6 +4046,57 @@ fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck {
]))
}
fn check_memory_health(context: &StatusContext) -> DiagnosticCheck {
let has_unloaded = !context.unloaded_memory_files.is_empty();
let mut details = vec![format!("Loaded files {}", context.memory_file_count)];
details.extend(context.memory_files.iter().map(|file| {
format!(
"Loaded {} ({}, chars={})",
file.path, file.source, file.chars
)
}));
details.extend(
context
.unloaded_memory_files
.iter()
.map(|path| format!("Unloaded {path}")),
);
DiagnosticCheck::new(
"Memory",
if has_unloaded {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
if has_unloaded {
"some workspace memory files exist but were not loaded".to_string()
} else {
format!("{} workspace memory files loaded", context.memory_file_count)
},
)
.with_hint(if has_unloaded {
"Move instructions into CLAUDE.md, CLAW.md, or AGENTS.md within the current workspace ancestry, or inspect workspace.memory_files in `claw status --output-format json`."
} else {
""
})
.with_details(details)
.with_data(Map::from_iter([
(
"memory_file_count".to_string(),
json!(context.memory_file_count),
),
(
"memory_files".to_string(),
Value::Array(memory_files_json(&context.memory_files)),
),
(
"unloaded_memory_files".to_string(),
json!(context.unloaded_memory_files),
),
]))
}
fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck {
let preflight = &context.boot_preflight;
let missing_binaries = preflight
@@ -4413,13 +4492,14 @@ fn print_system_prompt(
model: &str,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let sections = load_system_prompt(
let (sections, project_context) = load_system_prompt_with_context(
cwd,
date,
env::consts::OS,
"unknown",
model_family_identity_for(model),
)?;
let memory_files = memory_file_summaries(&project_context.instruction_files);
let message = sections.join(
"
@@ -4435,6 +4515,8 @@ fn print_system_prompt(
"status": "ok",
"message": message,
"sections": sections,
"memory_file_count": memory_files.len(),
"memory_files": memory_files_json(&memory_files),
}))?
),
}
@@ -4672,6 +4754,63 @@ struct ResumeCommandOutcome {
json: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MemoryFileSummary {
path: String,
source: String,
chars: usize,
contributes: bool,
}
impl MemoryFileSummary {
fn json_value(&self) -> serde_json::Value {
json!({
"path": self.path,
"source": self.source,
"chars": self.chars,
"contributes": self.contributes,
})
}
}
fn memory_file_summaries(files: &[ContextFile]) -> Vec<MemoryFileSummary> {
files
.iter()
.map(|file| MemoryFileSummary {
path: file.path.display().to_string(),
source: file.source().to_string(),
chars: file.char_count(),
contributes: true,
})
.collect()
}
fn memory_files_json(files: &[MemoryFileSummary]) -> Vec<serde_json::Value> {
files.iter().map(MemoryFileSummary::json_value).collect()
}
fn unloaded_memory_candidates(cwd: &Path, files: &[MemoryFileSummary]) -> Vec<String> {
let mut loaded = files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect::<Vec<_>>();
loaded.sort();
let mut missing = Vec::new();
let mut cursor = Some(cwd);
while let Some(dir) = cursor {
for name in ["CLAW.md", "AGENTS.md"] {
let candidate = dir.join(name);
if candidate.is_file() && !loaded.iter().any(|path| path == &candidate) {
missing.push(candidate.display().to_string());
}
}
cursor = dir.parent();
}
missing.sort();
missing.dedup();
missing
}
#[derive(Debug, Clone)]
struct StatusContext {
cwd: PathBuf,
@@ -4679,6 +4818,8 @@ struct StatusContext {
loaded_config_files: usize,
discovered_config_files: usize,
memory_file_count: usize,
memory_files: Vec<MemoryFileSummary>,
unloaded_memory_files: Vec<String>,
project_root: Option<PathBuf>,
git_branch: Option<String>,
git_summary: GitWorkspaceSummary,
@@ -8677,6 +8818,8 @@ fn status_json_value(
"loaded_config_files": context.loaded_config_files,
"discovered_config_files": context.discovered_config_files,
"memory_file_count": context.memory_file_count,
"memory_files": memory_files_json(&context.memory_files),
"unloaded_memory_files": context.unloaded_memory_files,
},
"sandbox": {
"enabled": context.sandbox_status.enabled,
@@ -8751,6 +8894,11 @@ fn status_context(
loaded_config_files,
discovered_config_files,
memory_file_count: project_context.instruction_files.len(),
memory_files: memory_file_summaries(&project_context.instruction_files),
unloaded_memory_files: unloaded_memory_candidates(
&cwd,
&memory_file_summaries(&project_context.instruction_files),
),
project_root,
git_branch,
git_summary,
@@ -8866,6 +9014,7 @@ fn format_status_report(
Boot preflight {}
Config files loaded {}/{}
Memory files {}
Loaded memory {}
Suggested flow /status → /diff → /commit",
context.cwd.display(),
context
@@ -8892,6 +9041,16 @@ fn format_status_report(
context.loaded_config_files,
context.discovered_config_files,
context.memory_file_count,
if context.memory_files.is_empty() {
"<none>".to_string()
} else {
context
.memory_files
.iter()
.map(|file| format!("{}:{}", file.source, file.path))
.collect::<Vec<_>>()
.join(", ")
},
),
format_sandbox_report(&context.sandbox_status),
]);
@@ -9325,7 +9484,7 @@ fn render_doctor_help_json() -> serde_json::Value {
"command": "doctor",
"schema_version": "1.0",
"usage": "claw doctor [--output-format <format>]",
"purpose": "diagnose local auth, config, workspace, permissions, sandbox, boot preflight, and build metadata",
"purpose": "diagnose local auth, config, workspace memory, permissions, sandbox, boot preflight, and build metadata",
"formats": ["text", "json"],
"local_only": true,
"requires_credentials": false,
@@ -9333,7 +9492,7 @@ fn render_doctor_help_json() -> serde_json::Value {
"requires_session_resume": false,
"mutates_workspace": false,
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks", "allowed_tools"],
"check_names": ["auth", "config", "install source", "workspace", "boot preflight", "sandbox", "permissions", "system"],
"check_names": ["auth", "config", "install source", "workspace", "memory", "boot preflight", "sandbox", "permissions", "system"],
"status_values": ["ok", "warn", "fail"],
"options": [
{
@@ -9745,7 +9904,7 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push(
" No CLAUDE instruction files discovered in the current directory ancestry."
" No CLAUDE.md, CLAW.md, AGENTS.md, or scoped instruction files discovered in the current directory ancestry."
.to_string(),
);
} else {
@@ -9759,8 +9918,10 @@ fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
};
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!(
" lines={} preview={}",
" source={} lines={} chars={} preview={}",
file.source(),
file.content.lines().count(),
file.char_count(),
preview
));
}
@@ -16283,6 +16444,13 @@ mod tests {
loaded_config_files: 2,
discovered_config_files: 3,
memory_file_count: 4,
memory_files: vec![super::MemoryFileSummary {
path: "/tmp/project/CLAUDE.md".to_string(),
source: "claude_md".to_string(),
chars: 42,
contributes: true,
}],
unloaded_memory_files: Vec::new(),
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
git_summary: GitWorkspaceSummary {
@@ -16327,6 +16495,7 @@ mod tests {
status.contains("Git state dirty · 3 files · 1 staged, 1 unstaged, 1 untracked")
);
assert!(status.contains("Changed files 3"));
assert!(status.contains("Loaded memory claude_md:/tmp/project/CLAUDE.md"));
assert!(status.contains("Staged 1"));
assert!(status.contains("Unstaged 1"));
assert!(status.contains("Untracked 1"));
@@ -16433,6 +16602,8 @@ mod tests {
loaded_config_files: 0,
discovered_config_files: 0,
memory_file_count: 0,
memory_files: Vec::new(),
unloaded_memory_files: Vec::new(),
project_root: Some(PathBuf::from("/tmp/project")),
git_branch: Some("feature/stale-base".to_string()),
git_summary: GitWorkspaceSummary::default(),
@@ -16467,6 +16638,52 @@ mod tests {
.any(|detail| detail.contains("stale codebase")));
}
#[test]
fn memory_health_surfaces_loaded_and_unloaded_files_438() {
let context = super::StatusContext {
cwd: PathBuf::from("/tmp/project"),
session_path: None,
loaded_config_files: 0,
discovered_config_files: 0,
memory_file_count: 1,
memory_files: vec![super::MemoryFileSummary {
path: "/tmp/project/CLAUDE.md".to_string(),
source: "claude_md".to_string(),
chars: 12,
contributes: true,
}],
unloaded_memory_files: vec!["/tmp/project/AGENTS.md".to_string()],
project_root: Some(PathBuf::from("/tmp/project")),
git_branch: Some("main".to_string()),
git_summary: GitWorkspaceSummary::default(),
branch_freshness: test_branch_freshness(),
stale_base_state: super::BaseCommitState::NoExpectedBase,
session_lifecycle: SessionLifecycleSummary {
kind: SessionLifecycleKind::SavedOnly,
pane_id: None,
pane_command: None,
pane_path: None,
workspace_dirty: false,
abandoned: false,
},
boot_preflight: test_boot_preflight(),
sandbox_status: runtime::SandboxStatus::default(),
binary_provenance: super::binary_provenance_for(None),
config_load_error: None,
config_load_error_kind: None,
};
let check = super::check_memory_health(&context);
assert_eq!(check.level, super::DiagnosticLevel::Warn);
assert_eq!(check.data["memory_file_count"], 1);
assert_eq!(check.data["memory_files"][0]["source"], "claude_md");
assert_eq!(
check.data["unloaded_memory_files"][0],
"/tmp/project/AGENTS.md"
);
}
#[test]
fn status_json_surfaces_session_lifecycle_for_clawhip() {
let context = super::StatusContext {
@@ -16475,6 +16692,8 @@ mod tests {
loaded_config_files: 0,
discovered_config_files: 0,
memory_file_count: 0,
memory_files: Vec::new(),
unloaded_memory_files: Vec::new(),
project_root: Some(PathBuf::from("/tmp/project")),
git_branch: Some("feature/session-lifecycle".to_string()),
git_summary: GitWorkspaceSummary::default(),

View File

@@ -110,6 +110,7 @@ fn assert_doctor_help_json_contract(parsed: &Value) {
let checks = parsed["check_names"].as_array().expect("check_names");
assert!(checks.iter().any(|check| check == "auth"));
assert!(checks.iter().any(|check| check == "boot preflight"));
assert!(checks.iter().any(|check| check == "memory"));
}
#[test]
@@ -1270,6 +1271,70 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
.contains("interactive agent"));
}
#[test]
fn memory_files_load_claude_claw_agents_and_surface_json_438() {
let root = unique_temp_dir("memory-files-438");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&root).expect("temp dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::write(root.join("CLAUDE.md"), "MARKER-FROM-CLAUDE-MD\n").expect("write CLAUDE.md");
fs::write(root.join("CLAW.md"), "MARKER-FROM-CLAW-MD\n").expect("write CLAW.md");
fs::write(root.join("AGENTS.md"), "MARKER-FROM-AGENTS-MD\n").expect("write AGENTS.md");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
];
let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(status["workspace"]["memory_file_count"], 3);
let memory_files = status["workspace"]["memory_files"]
.as_array()
.expect("status memory files");
let sources = memory_files
.iter()
.map(|file| file["source"].as_str().expect("memory source"))
.collect::<Vec<_>>();
assert_eq!(sources, vec!["claude_md", "claw_md", "agents_md"]);
assert!(memory_files
.iter()
.all(|file| file["path"].as_str().is_some()));
assert!(memory_files
.iter()
.all(|file| file["chars"].as_u64().unwrap_or(0) > 0));
assert!(memory_files
.iter()
.all(|file| file["contributes"].as_bool() == Some(true)));
let prompt =
assert_json_command_with_env(&root, &["--output-format", "json", "system-prompt"], &envs);
let message = prompt["message"].as_str().expect("prompt message");
assert!(message.contains("MARKER-FROM-CLAUDE-MD"));
assert!(message.contains("MARKER-FROM-CLAW-MD"));
assert!(message.contains("MARKER-FROM-AGENTS-MD"));
assert_eq!(prompt["memory_file_count"], 3);
assert_eq!(prompt["memory_files"][1]["source"], "claw_md");
let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs);
let memory = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "memory")
.expect("memory check");
assert_eq!(memory["status"], "ok");
assert_eq!(memory["memory_file_count"], 3);
assert_eq!(memory["memory_files"][2]["source"], "agents_md");
assert!(memory["unloaded_memory_files"]
.as_array()
.expect("unloaded memory files")
.is_empty());
}
#[test]
fn dump_manifests_and_init_emit_json_when_requested() {
let root = unique_temp_dir("manifest-init-json");
@@ -1325,7 +1390,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
.is_some_and(|available| available.iter().any(|name| name == "web_fetch")));
let checks = doctor["checks"].as_array().expect("doctor checks");
assert_eq!(checks.len(), 8);
assert_eq!(checks.len(), 9);
let check_names = checks
.iter()
.map(|check| {
@@ -1348,6 +1413,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
"config",
"install source",
"workspace",
"memory",
"boot preflight",
"sandbox",
"permissions",