diff --git a/ROADMAP.md b/ROADMAP.md index 102be48f..828d7bd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ` 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//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. diff --git a/USAGE.md b/USAGE.md index e44237c9..3434862e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -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: - `/.claw/rules/` (`.md`, `.txt`, `.mdc`) for shared project rules. - `/.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 diff --git a/rust/README.md b/rust/README.md index 6c837e63..30852051 100644 --- a/rust/README.md +++ b/rust/README.md @@ -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. diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 0f0eed8d..b54dedfb 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -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, diff --git a/rust/crates/runtime/src/prompt.rs b/rust/crates/runtime/src/prompt.rs index 1e5f2b1e..db1c0d5d 100644 --- a/rust/crates/runtime/src/prompt.rs +++ b/rust/crates/runtime/src/prompt.rs @@ -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) -> Vec { 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, model_family: ModelFamilyIdentity, ) -> Result, 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, + current_date: impl Into, + os_name: impl Into, + os_version: impl Into, + model_family: ModelFamilyIdentity, +) -> Result<(Vec, 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::>(); + 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")); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 79d922f7..92e95685 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -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() { + "".to_string() + } else { + context + .memory_files + .iter() + .map(|file| format!("{}:{}", file.source, file.path)) + .collect::>() + .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> { - 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, } +#[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 { + 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 { + files.iter().map(MemoryFileSummary::json_value).collect() +} + +fn unloaded_memory_candidates(cwd: &Path, files: &[MemoryFileSummary]) -> Vec { + let mut loaded = files + .iter() + .map(|file| PathBuf::from(&file.path)) + .collect::>(); + 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, + unloaded_memory_files: Vec, project_root: Option, git_branch: Option, 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() { + "".to_string() + } else { + context + .memory_files + .iter() + .map(|file| format!("{}:{}", file.source, file.path)) + .collect::>() + .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 ]", - "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> { 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> { }; 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(), diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 5a3f1c6b..3ad7c89e 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -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::>(); + 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",