From a45e66c634edc45c73ce3803daec7ec117e00858 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Fri, 17 Jul 2026 14:52:35 -0600 Subject: [PATCH] feat: Improved workspace instructions support --- README.md | 1 + assets/roles/repo-analyzer.md | 4 +- assets/skills/iwe-knowledge-base/SKILL.md | 3 +- config.example.yaml | 16 +- src/cli/mod.rs | 9 + src/config/app_config.rs | 9 + src/config/instructions.rs | 203 ++++++++++++++++++++++ src/config/memory.rs | 54 ++---- src/config/mod.rs | 8 +- src/config/paths.rs | 4 + src/config/request_context.rs | 33 ++++ src/function/memory.rs | 52 ++---- src/main.rs | 49 +++++- 13 files changed, 364 insertions(+), 81 deletions(-) create mode 100644 src/config/instructions.rs diff --git a/README.md b/README.md index 7bb8a9d..c6598da 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Coming from [AIChat](https://github.com/sigoden/aichat)? Follow the [migration g * [RAG](https://github.com/Dark-Alex-17/coyote/wiki/RAG): Retrieval-Augmented Generation for enhanced information retrieval and generation. * [Sessions](https://github.com/Dark-Alex-17/coyote/wiki/Sessions): Manage and persist conversational contexts and settings across multiple interactions. * [Memory](https://github.com/Dark-Alex-17/coyote/wiki/Memory): Persistent file-based memory that survives across sessions. Bootstrap with `coyote --init-memory [global|workspace]`. +* [Workspace Instructions](https://github.com/Dark-Alex-17/coyote/wiki/Workspace-Instructions): Human-curated project instructions (`COYOTE.md`) injected into every prompt, with `AGENTS.md`/`CLAUDE.md` fallbacks for cross-tool compatibility. Scaffold with `coyote --init-instructions`. * [Roles](https://github.com/Dark-Alex-17/coyote/wiki/Roles): Customize model behavior for specific tasks or domains. * [Skills](https://github.com/Dark-Alex-17/coyote/wiki/Skills): Modular knowledge or capability packs the LLM can load and unload mid-conversation. Multiple skills compose; instructions stack, tools and MCPs union. * [Agents](https://github.com/Dark-Alex-17/coyote/wiki/Agents): Leverage AI agents to perform complex tasks and workflows, including sub-agent spawning, teammate messaging, and user interaction tools. diff --git a/assets/roles/repo-analyzer.md b/assets/roles/repo-analyzer.md index ed46021..3b80f43 100644 --- a/assets/roles/repo-analyzer.md +++ b/assets/roles/repo-analyzer.md @@ -9,8 +9,8 @@ security/configuration settings. The analysis aims to ensure a thorough understa structured and operates, enabling the creation of new files, maintaining consistency with existing practices, and the potential implementation of best practices. -Should the root directory contain a `COYOTE.md` file, this was generated by Coyote and should be used as a reference -point for all analysis, style questions, etc. +Should the root directory contain a `COYOTE.md` (or `AGENTS.md`/`CLAUDE.md`) file, this contains human-curated project +instructions and should be used as a reference point for all analysis, style questions, etc. **Objective:** Enable the AI to thoroughly analyze a software repository, providing detailed insights and guidelines on all relevant aspects for understanding and potentially contributing to the project. diff --git a/assets/skills/iwe-knowledge-base/SKILL.md b/assets/skills/iwe-knowledge-base/SKILL.md index ed73b7c..71e7e50 100644 --- a/assets/skills/iwe-knowledge-base/SKILL.md +++ b/assets/skills/iwe-knowledge-base/SKILL.md @@ -10,7 +10,8 @@ Use IWE tools when the task involves a corpus of markdown documents: plan reposi Do NOT use IWE tools for: -- **Agent memory** (`.coyote/memory/`, `COYOTE.md`) — use the `memory__*` tools; they own the index conventions there. +- **Agent memory** (`.coyote/memory/`) — use the `memory__*` tools; they own the index conventions there. +- **Workspace instructions** (`COYOTE.md`, `AGENTS.md`, `CLAUDE.md`) — human-curated and read-only; never edit them with IWE write tools. - **Semantic/similarity search over documents** — that is RAG's job. IWE search is fuzzy title/key matching plus structural traversal, not embeddings. - **Source code** — IWE only understands markdown. diff --git a/config.example.yaml b/config.example.yaml index 022072e..5f1cb3f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -189,8 +189,8 @@ summary_context_prompt: > # The text prompt used for including the summar # ---- Memory ---- # See the [Memory documentation](https://github.com/Dark-Alex-17/coyote/wiki/Memory) for more information. -# Memory is opt-in by workspace presence (a `COYOTE.md` or `.coyote/memory/MEMORY.md`) -# and global presence (`/memory/MEMORY.md`). Set `memory: false` to disable +# Memory is opt-in by workspace presence (`.coyote/memory/MEMORY.md`) and global +# presence (`/memory/MEMORY.md`). Set `memory: false` to disable # even when memory files exist. The cascade is: agent > session > role > app. # Bootstrap with `coyote --init-memory [global|workspace]` to create the marker file # the LLM needs before it will write any memory. @@ -200,6 +200,18 @@ memory_cap_with_tools: null # Char cap for injected memory when function ca memory_cap_without_tools: null # Char cap when function calling is unavailable (default: 12000). # Indexes plus drill file bodies are injected up to this cap. +# ---- Workspace Instructions ---- +# Human-curated project instructions injected read-only into the system prompt, in full. +# Coyote walks up from the current directory and injects the first match from the file +# chain below (per directory, in order). Scaffold with `coyote --init-instructions`. +# Disable per-invocation with --no-workspace-instructions, or override the chain with +# repeatable --workspace-instructions-file flags. +workspace_instructions: null # null/true = inject when an instructions file exists; false = never inject +workspace_instructions_files: null # File name chain to search, in priority order. + # Default: [COYOTE.md, AGENTS.md, CLAUDE.md] + # Set to a custom list to reorder or drop fallbacks, e.g.: + # workspace_instructions_files: [COYOTE.md] + # ---- RAG ---- # See the [RAG Docs](https://github.com/Dark-Alex-17/coyote/wiki/RAG) for more details. rag_embedding_model: null # Specifies the embedding model used for context retrieval diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f340073..05a5a91 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -77,6 +77,12 @@ pub struct Cli { /// Disable memory for this invocation #[arg(long)] pub no_memory: bool, + /// Disable loading workspace instructions (COYOTE.md/AGENTS.md/CLAUDE.md/etc.) for this invocation + #[arg(long)] + pub no_workspace_instructions: bool, + /// Override the workspace instructions file chain for this invocation (repeatable, priority order) + #[arg(long, value_name = "NAME")] + pub workspace_instructions_file: Vec, /// Skip permission prompts by setting AUTO_CONFIRM for all tools (dangerous!) #[arg(long)] pub dangerously_skip_permissions: bool, @@ -98,6 +104,9 @@ pub struct Cli { help_heading = "Session & Memory" )] pub init_memory: Option, + /// Scaffold a COYOTE.md workspace instructions file in the current directory + #[arg(long, help_heading = "Session & Memory")] + pub init_instructions: bool, /// Pre-load an existing skill into the session (repeatable). If a single /// `--skill ` is given and the skill doesn't exist, opens $EDITOR /// with a scaffold to create it. diff --git a/src/config/app_config.rs b/src/config/app_config.rs index de9377a..a501bf8 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -69,6 +69,9 @@ pub struct AppConfig { pub memory_cap_with_tools: Option, pub memory_cap_without_tools: Option, + pub workspace_instructions: Option, + pub workspace_instructions_files: Option>, + pub rag_embedding_model: Option, pub rag_reranker_model: Option, pub rag_top_k: usize, @@ -146,6 +149,9 @@ impl Default for AppConfig { memory_cap_with_tools: None, memory_cap_without_tools: None, + workspace_instructions: None, + workspace_instructions_files: None, + rag_embedding_model: None, rag_reranker_model: None, rag_top_k: 5, @@ -224,6 +230,9 @@ impl AppConfig { memory_cap_with_tools: config.memory_cap_with_tools, memory_cap_without_tools: config.memory_cap_without_tools, + workspace_instructions: config.workspace_instructions, + workspace_instructions_files: config.workspace_instructions_files, + rag_embedding_model: config.rag_embedding_model, rag_reranker_model: config.rag_reranker_model, rag_top_k: config.rag_top_k, diff --git a/src/config/instructions.rs b/src/config/instructions.rs new file mode 100644 index 0000000..94fe566 --- /dev/null +++ b/src/config/instructions.rs @@ -0,0 +1,203 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use log::warn; + +pub const WORKSPACE_INSTRUCTIONS_FILE_NAME: &str = "COYOTE.md"; +pub const DEFAULT_WORKSPACE_INSTRUCTIONS_FILES: [&str; 3] = + [WORKSPACE_INSTRUCTIONS_FILE_NAME, "AGENTS.md", "CLAUDE.md"]; +const INSTRUCTIONS_SIZE_WARN_THRESHOLD: usize = 24_000; + +#[derive(Debug, Clone)] +pub struct WorkspaceInstructions { + pub path: PathBuf, + pub content: String, +} + +pub fn default_workspace_instructions_files() -> Vec { + DEFAULT_WORKSPACE_INSTRUCTIONS_FILES + .iter() + .map(|s| s.to_string()) + .collect() +} + +pub fn discover_workspace_instructions( + start: &Path, + file_names: &[String], +) -> Option { + for dir in start.ancestors() { + for name in file_names { + let candidate = dir.join(name); + if !candidate.is_file() { + continue; + } + match fs::read_to_string(&candidate) { + Ok(content) if !content.trim().is_empty() => { + return Some(WorkspaceInstructions { + path: candidate, + content, + }); + } + Ok(_) => {} + Err(e) => warn!( + "failed to read workspace instructions at {}: {e}", + candidate.display() + ), + } + } + } + None +} + +pub fn build_instructions_section(instructions: &WorkspaceInstructions) -> String { + let char_count = instructions.content.chars().count(); + if char_count > INSTRUCTIONS_SIZE_WARN_THRESHOLD { + warn!( + "workspace instructions at {} are large ({char_count} chars); \ + consider moving detail into workspace memory drill files", + instructions.path.display() + ); + } + + format!( + "\n{}\n", + instructions.path.display(), + instructions.content.trim_end() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{env, time}; + use time::SystemTime; + + fn temp_root(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("coyote-instructions-{label}-{unique}")); + fs::create_dir_all(&root).unwrap(); + root + } + + fn defaults() -> Vec { + default_workspace_instructions_files() + } + + #[test] + fn discovery_returns_none_when_no_file_exists() { + let root = temp_root("none"); + + assert!(discover_workspace_instructions(&root, &defaults()).is_none()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_finds_coyote_md() { + let root = temp_root("coyote"); + fs::write(root.join("COYOTE.md"), "coyote instructions").unwrap(); + + let found = discover_workspace_instructions(&root, &defaults()).unwrap(); + assert_eq!(found.path, root.join("COYOTE.md")); + assert_eq!(found.content, "coyote instructions"); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_falls_back_to_agents_md_then_claude_md() { + let root = temp_root("fallback"); + fs::write(root.join("CLAUDE.md"), "claude instructions").unwrap(); + + let found = discover_workspace_instructions(&root, &defaults()).unwrap(); + assert_eq!(found.path, root.join("CLAUDE.md")); + + fs::write(root.join("AGENTS.md"), "agents instructions").unwrap(); + let found = discover_workspace_instructions(&root, &defaults()).unwrap(); + assert_eq!(found.path, root.join("AGENTS.md")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_prefers_coyote_md_over_fallbacks() { + let root = temp_root("precedence"); + fs::write(root.join("COYOTE.md"), "coyote").unwrap(); + fs::write(root.join("AGENTS.md"), "agents").unwrap(); + fs::write(root.join("CLAUDE.md"), "claude").unwrap(); + + let found = discover_workspace_instructions(&root, &defaults()).unwrap(); + assert_eq!(found.path, root.join("COYOTE.md")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_walks_up_from_nested_dir() { + let root = temp_root("walk_up"); + fs::write(root.join("AGENTS.md"), "root instructions").unwrap(); + let nested = root.join("src").join("deep"); + fs::create_dir_all(&nested).unwrap(); + + let found = discover_workspace_instructions(&nested, &defaults()).unwrap(); + assert_eq!(found.path, root.join("AGENTS.md")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_prefers_closer_file_over_higher_priority_name_above() { + let root = temp_root("depth_first"); + fs::write(root.join("COYOTE.md"), "root coyote").unwrap(); + let nested = root.join("packages").join("app"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("CLAUDE.md"), "nested claude").unwrap(); + + let found = discover_workspace_instructions(&nested, &defaults()).unwrap(); + assert_eq!(found.path, nested.join("CLAUDE.md")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_skips_empty_files() { + let root = temp_root("empty"); + fs::write(root.join("COYOTE.md"), " \n").unwrap(); + fs::write(root.join("AGENTS.md"), "real content").unwrap(); + + let found = discover_workspace_instructions(&root, &defaults()).unwrap(); + assert_eq!(found.path, root.join("AGENTS.md")); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_honors_custom_file_chain() { + let root = temp_root("custom"); + fs::write(root.join("CLAUDE.md"), "claude").unwrap(); + + let only_agents = vec!["AGENTS.md".to_string()]; + assert!(discover_workspace_instructions(&root, &only_agents).is_none()); + + let empty: Vec = vec![]; + assert!(discover_workspace_instructions(&root, &empty).is_none()); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn build_section_wraps_content_with_source_path() { + let instructions = WorkspaceInstructions { + path: PathBuf::from("/ws/COYOTE.md"), + content: "Do the thing.\n".into(), + }; + + let section = build_instructions_section(&instructions); + assert!(section.starts_with("")); + assert!(section.contains("Do the thing.")); + assert!(section.ends_with("")); + } +} diff --git a/src/config/memory.rs b/src/config/memory.rs index 6041898..18d4e4a 100644 --- a/src/config/memory.rs +++ b/src/config/memory.rs @@ -7,41 +7,27 @@ use serde::{Deserialize, Serialize}; use crate::config::{ GIT_DIR_NAME, GITIGNORE_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, - WORKSPACE_COYOTE_DIR_NAME, WORKSPACE_MEMORY_FILE_NAME, paths, + WORKSPACE_COYOTE_DIR_NAME, paths, }; pub const DEFAULT_MEMORY_CAP_WITH_TOOLS: usize = 6_000; pub const DEFAULT_MEMORY_CAP_WITHOUT_TOOLS: usize = 12_000; #[derive(Debug, Clone)] -pub enum WorkspaceMemory { - Structured { - workspace_root: PathBuf, - dir: PathBuf, - }, - Lite { - workspace_root: PathBuf, - file: PathBuf, - }, +pub struct WorkspaceMemory { + pub workspace_root: PathBuf, + pub dir: PathBuf, } pub fn discover_workspace_memory(start: &Path) -> Option { for dir in start.ancestors() { let structured = dir.join(WORKSPACE_COYOTE_DIR_NAME).join(MEMORY_DIR_NAME); if structured.join(MEMORY_INDEX_FILE_NAME).exists() { - return Some(WorkspaceMemory::Structured { + return Some(WorkspaceMemory { workspace_root: dir.to_path_buf(), dir: structured, }); } - - let lite = dir.join(WORKSPACE_MEMORY_FILE_NAME); - if lite.exists() { - return Some(WorkspaceMemory::Lite { - workspace_root: dir.to_path_buf(), - file: lite, - }); - } } None } @@ -82,7 +68,7 @@ pub fn bootstrap_workspace_memory(git_root: &Path) -> Result { Ok(mem_dir) } -fn append_gitignore_entry(git_root: &Path) -> Result { +pub fn append_gitignore_entry(git_root: &Path) -> Result { let gitignore = git_root.join(GITIGNORE_FILE_NAME); let entry = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}/"); let entry_no_slash = format!("{WORKSPACE_COYOTE_DIR_NAME}/{MEMORY_DIR_NAME}"); @@ -212,9 +198,8 @@ impl MemoryStore { pub fn load_workspace_index(&self) -> Result> { match &self.workspace { None => Ok(None), - Some(WorkspaceMemory::Lite { file, .. }) => Ok(Some(fs::read_to_string(file)?)), - Some(WorkspaceMemory::Structured { dir, .. }) => { - let index = dir.join(MEMORY_INDEX_FILE_NAME); + Some(ws) => { + let index = ws.dir.join(MEMORY_INDEX_FILE_NAME); if index.exists() { Ok(Some(fs::read_to_string(index)?)) } else { @@ -231,8 +216,8 @@ impl MemoryStore { collect_md_files(&self.global_dir, &mut out)?; } - if let Some(WorkspaceMemory::Structured { dir, .. }) = &self.workspace { - collect_md_files(dir, &mut out)?; + if let Some(ws) = &self.workspace { + collect_md_files(&ws.dir, &mut out)?; } Ok(out) @@ -378,18 +363,13 @@ mod tests { } #[test] - fn workspace_discovery_prefers_structured_over_lite() { - let root = temp_root("prefer"); + fn workspace_discovery_ignores_root_instructions_file() { + let root = temp_root("no_lite"); let workspace = root.join("ws"); - let structured = workspace - .join(WORKSPACE_COYOTE_DIR_NAME) - .join(MEMORY_DIR_NAME); - fs::create_dir_all(&structured).unwrap(); - fs::write(structured.join(MEMORY_INDEX_FILE_NAME), "s").unwrap(); - fs::write(workspace.join(WORKSPACE_MEMORY_FILE_NAME), "l").unwrap(); + fs::create_dir_all(&workspace).unwrap(); + fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap(); - let found = discover_workspace_memory(&workspace); - assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); + assert!(discover_workspace_memory(&workspace).is_none()); let _ = fs::remove_dir_all(&root); } @@ -582,8 +562,8 @@ mod tests { let nested = workspace.join("src").join("deep").join("path"); fs::create_dir_all(&nested).unwrap(); - let found = discover_workspace_memory(&nested); - assert!(matches!(found, Some(WorkspaceMemory::Structured { .. }))); + let found = discover_workspace_memory(&nested).expect("workspace memory should be found"); + assert_eq!(found.dir, mem_dir); let _ = fs::remove_dir_all(&root); } diff --git a/src/config/mod.rs b/src/config/mod.rs index c29994e..3766302 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,6 +3,7 @@ mod app_config; mod app_state; mod input; mod install_remote; +pub(crate) mod instructions; mod macros; mod mcp_factory; pub(crate) mod memory; @@ -143,7 +144,6 @@ const MCP_FILE_NAME: &str = "mcp.json"; const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json"; const MEMORY_DIR_NAME: &str = "memory"; const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md"; -const WORKSPACE_MEMORY_FILE_NAME: &str = "COYOTE.md"; const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote"; const SBX_KIT_DIR_NAME: &str = "sbx-kit"; const SBX_KIT_HASH_FILE: &str = "kit.sha256"; @@ -245,6 +245,9 @@ pub struct Config { pub memory_cap_with_tools: Option, pub memory_cap_without_tools: Option, + pub workspace_instructions: Option, + pub workspace_instructions_files: Option>, + pub rag_embedding_model: Option, pub rag_reranker_model: Option, pub rag_top_k: usize, @@ -320,6 +323,9 @@ impl Default for Config { memory_cap_with_tools: None, memory_cap_without_tools: None, + workspace_instructions: None, + workspace_instructions_files: None, + rag_embedding_model: None, rag_reranker_model: None, rag_top_k: 5, diff --git a/src/config/paths.rs b/src/config/paths.rs index fd56d30..9ac45a9 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -356,6 +356,10 @@ pub fn workspace_memory_dir_for(workspace_root: &Path) -> PathBuf { .join(MEMORY_DIR_NAME) } +pub fn workspace_memory_index_path_for(workspace_root: &Path) -> PathBuf { + workspace_memory_dir_for(workspace_root).join(MEMORY_INDEX_FILE_NAME) +} + pub fn repl_history_dir() -> PathBuf { cache_path().join(REPL_HISTORY_DIR_NAME) } diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 04f4089..3f79df8 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -32,6 +32,7 @@ use crate::utils::{ list_file_names, now, render_prompt, temp_file, }; +use super::instructions; use super::memory::{ DEFAULT_MEMORY_CAP_WITH_TOOLS, DEFAULT_MEMORY_CAP_WITHOUT_TOOLS, MemoryStore, WorkspaceMemory, }; @@ -797,6 +798,20 @@ impl RequestContext { self.session.as_ref(), )?; + if app.workspace_instructions.unwrap_or(true) + && let Ok(cwd) = env::current_dir() + { + let file_names = app + .workspace_instructions_files + .clone() + .unwrap_or_else(instructions::default_workspace_instructions_files); + if let Some(found) = instructions::discover_workspace_instructions(&cwd, &file_names) { + let separator = if role.is_empty_prompt() { "" } else { "\n\n" }; + role.append_to_prompt(separator); + role.append_to_prompt(&instructions::build_instructions_section(&found)); + } + } + if should_inject_skill_instructions(app, &policy) { let config = self.skill_instructions_config(); @@ -1470,6 +1485,24 @@ impl RequestContext { "memory_cap_without_tools", super::format_option_value(&app.memory_cap_without_tools), ), + ( + "workspace_instructions", + super::format_option_value(&app.workspace_instructions), + ), + ( + "workspace_instructions_file", + env::current_dir() + .ok() + .and_then(|cwd| { + let file_names = app + .workspace_instructions_files + .clone() + .unwrap_or_else(instructions::default_workspace_instructions_files); + instructions::discover_workspace_instructions(&cwd, &file_names) + }) + .map(|i| i.path.display().to_string()) + .unwrap_or_else(|| "null".into()), + ), ( "rag_reranker_model", super::format_option_value(&rag_reranker_model), diff --git a/src/function/memory.rs b/src/function/memory.rs index 65f0ac3..7782a44 100644 --- a/src/function/memory.rs +++ b/src/function/memory.rs @@ -474,7 +474,7 @@ fn rename_memory(store: &MemoryStore, cwd: &Path, args: &Value) -> Result let description = renamed.frontmatter.description.clone().unwrap_or_default(); ensure_index_entry(&index_path, &new_name, &description)?; - // Other indexes (other scope's MEMORY.md, lite COYOTE.md): rewrite wikilinks only. + // Other indexes (other scope's MEMORY.md): rewrite wikilinks only. for other_index in other_index_paths(store, &target_dir) { if let Ok(existing) = fs::read_to_string(&other_index) && existing.contains(&needle) @@ -539,17 +539,11 @@ fn other_index_paths(store: &MemoryStore, own_dir: &Path) -> Vec { out.push(global_index); } - match &store.workspace { - Some(WorkspaceMemory::Structured { dir, .. }) => { - let index = dir.join("MEMORY.md"); - if dir.as_path() != own_dir && index.exists() { - out.push(index); - } + if let Some(ws) = &store.workspace { + let index = ws.dir.join("MEMORY.md"); + if ws.dir.as_path() != own_dir && index.exists() { + out.push(index); } - Some(WorkspaceMemory::Lite { file, .. }) if file.exists() => { - out.push(file.clone()); - } - _ => {} } out @@ -637,10 +631,7 @@ fn find_file(store: &MemoryStore, name: &str) -> Result> { fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result { match &store.workspace { - Some(WorkspaceMemory::Structured { dir, .. }) => Ok(dir.clone()), - Some(WorkspaceMemory::Lite { workspace_root, .. }) => { - Ok(paths::workspace_memory_dir_for(workspace_root)) - } + Some(ws) => Ok(ws.dir.clone()), None => match find_git_root(cwd) { Some(git_root) => bootstrap_workspace_memory(&git_root), None => bail!( @@ -652,20 +643,10 @@ fn workspace_write_dir(store: &MemoryStore, cwd: &Path) -> Result { } fn workspace_label(w: &WorkspaceMemory) -> Value { - match w { - WorkspaceMemory::Structured { workspace_root, .. } => json!({ - "mode": "structured", - "root": workspace_root.display().to_string(), - }), - WorkspaceMemory::Lite { - workspace_root, - file, - } => json!({ - "mode": "lite", - "root": workspace_root.display().to_string(), - "file": file.display().to_string(), - }), - } + json!({ + "root": w.workspace_root.display().to_string(), + "dir": w.dir.display().to_string(), + }) } fn lint_memory(store: &MemoryStore) -> Result { @@ -872,19 +853,24 @@ mod tests { } #[test] - fn workspace_write_dir_promotes_lite_to_structured_subdir() { - let root = temp_root("ws_lite_promote"); + fn workspace_write_dir_treats_root_instructions_file_as_no_memory() { + let root = temp_root("ws_instructions_only"); let workspace = root.join("ws"); - fs::create_dir_all(&workspace).unwrap(); - fs::write(workspace.join("COYOTE.md"), "lite").unwrap(); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::write(workspace.join("COYOTE.md"), "instructions, not memory").unwrap(); let store = MemoryStore { global_dir: root.join("g"), workspace: discover_workspace_memory(&workspace), }; + assert!(store.workspace.is_none(), "COYOTE.md must not be memory"); let dir = workspace_write_dir(&store, &workspace).unwrap(); assert_eq!(dir, workspace.join(".coyote").join("memory")); + assert!( + dir.join("MEMORY.md").exists(), + "bootstrap must create index" + ); let _ = fs::remove_dir_all(&root); } diff --git a/src/main.rs b/src/main.rs index 64cc0f3..c29bf72 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,12 +21,13 @@ use crate::cli::Cli; use crate::client::{ ModelType, call_chat_completions, call_chat_completions_streaming, list_models, oauth, }; -use crate::config::paths; +use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME; use crate::config::{ Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists, install_builtins, list_agents, load_env_file, macro_execute, sync_models, }; +use crate::config::{memory, paths}; use crate::function::supervisor::{GuardrailAction, check_pending_agents_guardrail}; use crate::mcp::McpServersConfig; use crate::render::{prompt_theme, render_error}; @@ -369,6 +370,15 @@ async fn run( if cli.no_memory { update_app_config(&mut ctx, |app| app.memory = Some(false)); } + if cli.no_workspace_instructions { + update_app_config(&mut ctx, |app| app.workspace_instructions = Some(false)); + } + if !cli.workspace_instructions_file.is_empty() { + let files = cli.workspace_instructions_file.clone(); + update_app_config(&mut ctx, |app| { + app.workspace_instructions_files = Some(files); + }); + } if cli.empty_session { ctx.empty_session()?; } @@ -381,10 +391,14 @@ async fn run( paths::global_memory_index_path(), "# Global Memory\n\n\n\n", ), - MemoryScope::Workspace => ( - env::current_dir()?.join("COYOTE.md"), - "# Workspace Memory\n\n\n", - ), + MemoryScope::Workspace => { + let cwd = env::current_dir()?; + let root = memory::find_git_root(&cwd).unwrap_or(cwd); + ( + paths::workspace_memory_index_path_for(&root), + "# Workspace Memory Index\n\n\n\n", + ) + } }; if path.exists() { @@ -397,9 +411,34 @@ async fn run( } fs::write(&path, content)?; + if scope == MemoryScope::Workspace + && let Some(git_root) = memory::find_git_root(&path) + { + memory::append_gitignore_entry(&git_root)?; + } println!("✓ Created memory marker at '{}'.", path.display()); return Ok(()); } + if cli.init_instructions { + let path = env::current_dir()?.join(WORKSPACE_INSTRUCTIONS_FILE_NAME); + + if path.exists() { + eprintln!( + "Workspace instructions already exist at '{}'.", + path.display() + ); + + return Ok(()); + } + + fs::write( + &path, + "# Project Instructions\n\n\n\n", + )?; + println!("✓ Created workspace instructions at '{}'.", path.display()); + + return Ok(()); + } if cli.info { let app: Arc = Arc::clone(&ctx.app.config); let info = ctx.info(app.as_ref())?;