diff --git a/crates/claudear-core/src/types.rs b/crates/claudear-core/src/types.rs index 51fc4b0..118c200 100644 --- a/crates/claudear-core/src/types.rs +++ b/crates/claudear-core/src/types.rs @@ -1923,6 +1923,50 @@ pub struct PromotedInstruction { pub updated_at: DateTime, } +/// Scope of an operator-authored agent instruction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InstructionScope { + /// Applies to every repo. + Global, + /// Applies to a single repo (keyed by `org/name`). + Repo, +} + +impl std::fmt::Display for InstructionScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Global => write!(f, "global"), + Self::Repo => write!(f, "repo"), + } + } +} + +impl std::str::FromStr for InstructionScope { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "global" => Ok(Self::Global), + "repo" => Ok(Self::Repo), + _ => Err(format!("Unknown instruction scope: {}", s)), + } + } +} + +/// Operator-authored instruction injected into the agent's context on every run. +/// Scoped either globally or to a single repo; see `InstructionScope`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentInstruction { + pub id: i64, + pub scope: InstructionScope, + /// Some(`org/name`) when scope is `Repo`; None for global. + pub repo: Option, + pub instruction_text: String, + pub is_active: bool, + pub updated_at: DateTime, +} + /// Per-repo accumulated knowledge entry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoKnowledge { diff --git a/crates/claudear-engine/src/api/routes.rs b/crates/claudear-engine/src/api/routes.rs index 4732954..d8ce362 100644 --- a/crates/claudear-engine/src/api/routes.rs +++ b/crates/claudear-engine/src/api/routes.rs @@ -156,6 +156,10 @@ pub fn create_api_router_full( ) .route("/api/repos/dependencies", get(dependencies_handler)) .route("/api/repos/{repo}/learning", get(repo_learning_handler)) + .route( + "/api/repos/{repo}/instructions", + get(get_repo_instruction_handler).put(put_repo_instruction_handler), + ) .route("/api/channels", get(discord_channels_handler)) .route("/api/channels/stats", get(discord_channel_stats_handler)) .route("/api/inference/stats", get(inference_stats_handler)) @@ -185,6 +189,10 @@ pub fn create_api_router_full( "/api/config", axum::routing::get(get_config_handler).put(put_config_handler), ) + .route( + "/api/instructions/global", + axum::routing::get(get_global_instruction_handler).put(put_global_instruction_handler), + ) // User CRUD routes .route( "/api/users", @@ -2732,6 +2740,132 @@ async fn put_config_handler( )) } +#[derive(Serialize)] +struct InstructionResponse { + scope: String, + repo: Option, + text: String, + updated_at: Option, +} + +#[derive(Deserialize)] +struct InstructionUpdateRequest { + text: String, +} + +fn instruction_response( + scope: claudear_core::types::InstructionScope, + repo: Option, + instruction: Option, +) -> InstructionResponse { + match instruction { + Some(i) => InstructionResponse { + scope: i.scope.to_string(), + repo: i.repo, + text: i.instruction_text, + updated_at: Some(i.updated_at.to_rfc3339()), + }, + None => InstructionResponse { + scope: scope.to_string(), + repo, + text: String::new(), + updated_at: None, + }, + } +} + +/// GET /api/instructions/global — read the global agent instruction. +async fn get_global_instruction_handler( + _user: AdminUser, + State(state): State, +) -> Result, StatusCode> { + let instruction = state + .tracker + .get_agent_instruction(claudear_core::types::InstructionScope::Global, None) + .map_err(|e| { + tracing::error!(error = %e, "Failed to read global instruction"); + sentry::capture_error(&e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json(instruction_response( + claudear_core::types::InstructionScope::Global, + None, + instruction, + ))) +} + +/// PUT /api/instructions/global — write the global agent instruction. +async fn put_global_instruction_handler( + _user: AdminUser, + State(state): State, + Json(body): Json, +) -> Result, StatusCode> { + if !check_api_rate_limit(_user.0.id) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + state + .tracker + .upsert_agent_instruction( + claudear_core::types::InstructionScope::Global, + None, + &body.text, + Some(&_user.0.name), + ) + .map_err(|e| { + tracing::error!(error = %e, "Failed to save global instruction"); + sentry::capture_error(&e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + +/// GET /api/repos/{repo}/instructions — read a repo's agent instruction. +async fn get_repo_instruction_handler( + _user: AdminUser, + State(state): State, + Path(repo): Path, +) -> Result, StatusCode> { + let instruction = state + .tracker + .get_agent_instruction(claudear_core::types::InstructionScope::Repo, Some(&repo)) + .map_err(|e| { + tracing::error!(error = %e, "Failed to read repo instruction"); + sentry::capture_error(&e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json(instruction_response( + claudear_core::types::InstructionScope::Repo, + Some(repo), + instruction, + ))) +} + +/// PUT /api/repos/{repo}/instructions — write a repo's agent instruction. +async fn put_repo_instruction_handler( + _user: AdminUser, + State(state): State, + Path(repo): Path, + Json(body): Json, +) -> Result, StatusCode> { + if !check_api_rate_limit(_user.0.id) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + state + .tracker + .upsert_agent_instruction( + claudear_core::types::InstructionScope::Repo, + Some(&repo), + &body.text, + Some(&_user.0.name), + ) + .map_err(|e| { + tracing::error!(error = %e, "Failed to save repo instruction"); + sentry::capture_error(&e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(Json(serde_json::json!({ "ok": true }))) +} + /// Browse GGUF models from HuggingFace. async fn browse_models_handler( _user: AuthUser, diff --git a/crates/claudear-engine/src/processing.rs b/crates/claudear-engine/src/processing.rs index 6632073..f5e52fb 100644 --- a/crates/claudear-engine/src/processing.rs +++ b/crates/claudear-engine/src/processing.rs @@ -577,6 +577,7 @@ impl IssueProcessor { json!({}), ); let (context, _discord_refs) = self.build_rag_context(issue, attempt_id).await; + let context = self.prepend_operator_instructions(context, resolution.repo_name()); let prompt = build_failing_test_prompt(issue, &context); match self .agent @@ -1304,6 +1305,10 @@ impl IssueProcessor { ); } + // Prepend operator instructions so the agent knows this repo's role + // (e.g. generated output vs source) before it starts editing. + let mut context = self.prepend_operator_instructions(context, resolution.repo_name()); + // Claude execution + ask loop let mut rounds: u8 = 0; let claude_result = loop { @@ -2216,6 +2221,9 @@ impl IssueProcessor { // Ground the answer in the reply thread when this question is a reply. let context = self.with_reply_chain(issue, context).await; + // QA answers still honor operator instructions (global always; per-repo + // when a repo resolved). + let context = self.prepend_operator_instructions(context, resolution.repo_name()); self.record_issue_decision( issue, @@ -2525,6 +2533,7 @@ impl IssueProcessor { let project_dir = self.action_project_dir(resolution); // Verify is read-only and posts no user-facing message, so drop the refs. let (context, _discord_refs) = self.build_rag_context(issue, attempt_id).await; + let context = self.prepend_operator_instructions(context, resolution.repo_name()); self.record_issue_decision( issue, @@ -2645,6 +2654,7 @@ impl IssueProcessor { ) -> ProcessingOutcome { let project_dir = self.action_project_dir(resolution); let (context, discord_refs) = self.build_rag_context(issue, attempt_id).await; + let context = self.prepend_operator_instructions(context, resolution.repo_name()); // The inbox key is the HelpScout mailbox id when present, else the source. let inbox_key = issue @@ -2781,6 +2791,36 @@ impl IssueProcessor { } } + /// Prepend operator-authored instructions (global + per-repo) to the agent + /// context so it knows this repo's role and any cross-repo relationships + /// before acting. No-op when the repo is unknown or nothing is configured. + /// Prompt-string only: never writes files, so a repo's own AGENTS.md is + /// left untouched. + fn prepend_operator_instructions(&self, context: String, repo: Option<&str>) -> String { + // repo is None for runs without a resolved repo (QA answers, skipped + // resolution); global instructions still apply in that case. + match self.tracker.resolve_agent_instructions(repo) { + Ok(Some(block)) => { + if context.is_empty() { + block + } else { + format!("{block}\n\n{context}") + } + } + Ok(None) => context, + Err(e) => { + // Fail open so a storage hiccup never blocks a run, but surface + // it: the agent proceeds without operator constraints here. + tracing::warn!( + error = %e, + repo = ?repo, + "Failed to resolve operator instructions; proceeding without them" + ); + context + } + } + } + /// Retrieve RAG grounding context for an issue from the code index, plus any /// indexed Discord discussions. /// Build the RAG grounding context for the action pipeline (verify/reply). diff --git a/crates/claudear-storage/src/lib.rs b/crates/claudear-storage/src/lib.rs index 2337980..fd9f191 100644 --- a/crates/claudear-storage/src/lib.rs +++ b/crates/claudear-storage/src/lib.rs @@ -857,6 +857,32 @@ pub trait KnowledgeStore: Send + Sync { Ok(Vec::new()) } + /// Upsert the single agent-instruction row for a scope (None repo = global). + fn upsert_agent_instruction( + &self, + _scope: claudear_core::types::InstructionScope, + _repo: Option<&str>, + _text: &str, + _updated_by: Option<&str>, + ) -> Result { + Ok(0) + } + + /// Get the active agent instruction for a scope, if any. + fn get_agent_instruction( + &self, + _scope: claudear_core::types::InstructionScope, + _repo: Option<&str>, + ) -> Result> { + Ok(None) + } + + /// Resolve the effective instruction block (global + per-repo). `repo` is + /// None when there is no resolved repo; global instructions still apply. + fn resolve_agent_instructions(&self, _repo: Option<&str>) -> Result> { + Ok(None) + } + /// System 4: Upsert a repo knowledge entry. fn upsert_repo_knowledge(&self, _entry: &claudear_core::types::RepoKnowledge) -> Result { Ok(0) diff --git a/crates/claudear-storage/src/migrator.rs b/crates/claudear-storage/src/migrator.rs index cc3c728..b1ea154 100644 --- a/crates/claudear-storage/src/migrator.rs +++ b/crates/claudear-storage/src/migrator.rs @@ -61,6 +61,11 @@ const MIGRATIONS: &[Migration] = &[ name: "pr_review_states_issue_comments", sql: include_str!("../../../migrations/V9__pr_review_states_issue_comments.sql"), }, + Migration { + version: 10, + name: "agent_instructions", + sql: include_str!("../../../migrations/V10__agent_instructions.sql"), + }, ]; /// Run all pending migrations against the given connection. @@ -121,7 +126,7 @@ mod tests { row.get(0) }) .unwrap(); - assert_eq!(version, 9); + assert_eq!(version, 10); // Verify a table from V1 exists let count: u32 = conn @@ -171,6 +176,16 @@ mod tests { ) .unwrap(); assert_eq!(has_kind_col, 1); + + // Verify the V10 table exists. + let has_instructions: u32 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='agent_instructions'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(has_instructions, 1); } #[test] @@ -185,7 +200,7 @@ mod tests { row.get(0) }) .unwrap(); - assert_eq!(version, 9); + assert_eq!(version, 10); } #[test] diff --git a/crates/claudear-storage/src/sqlite.rs b/crates/claudear-storage/src/sqlite.rs index 0c48e2b..8e033e6 100644 --- a/crates/claudear-storage/src/sqlite.rs +++ b/crates/claudear-storage/src/sqlite.rs @@ -3133,6 +3133,28 @@ impl KnowledgeStore for SqliteTracker { SqliteTracker::get_promoted_instructions(self, repo) } + fn upsert_agent_instruction( + &self, + scope: claudear_core::types::InstructionScope, + repo: Option<&str>, + text: &str, + updated_by: Option<&str>, + ) -> Result { + SqliteTracker::upsert_agent_instruction(self, scope, repo, text, updated_by) + } + + fn get_agent_instruction( + &self, + scope: claudear_core::types::InstructionScope, + repo: Option<&str>, + ) -> Result> { + SqliteTracker::get_agent_instruction(self, scope, repo) + } + + fn resolve_agent_instructions(&self, repo: Option<&str>) -> Result> { + SqliteTracker::resolve_agent_instructions(self, repo) + } + fn upsert_repo_knowledge(&self, entry: &claudear_core::types::RepoKnowledge) -> Result { SqliteTracker::upsert_repo_knowledge(self, entry) } @@ -7799,6 +7821,115 @@ impl SqliteTracker { Ok(rows) } + /// Upsert the single agent-instruction row for a scope. `repo` is None for + /// the global row and Some(`org/name`) for a per-repo row. + pub fn upsert_agent_instruction( + &self, + scope: claudear_core::types::InstructionScope, + repo: Option<&str>, + text: &str, + updated_by: Option<&str>, + ) -> Result { + let conn = self.acquire_lock()?; + let now = Utc::now().to_rfc3339(); + let scope_str = scope.to_string(); + + // Match on IFNULL so the NULL-repo global row is addressable. + let updated = conn.execute( + "UPDATE agent_instructions SET instruction_text = ?1, is_active = 1, updated_by = ?2, updated_at = ?3 + WHERE scope = ?4 AND IFNULL(repo, '') = IFNULL(?5, '')", + params![text, updated_by, now, scope_str, repo], + )?; + + if updated > 0 { + let id: i64 = conn + .query_row( + "SELECT id FROM agent_instructions WHERE scope = ?1 AND IFNULL(repo, '') = IFNULL(?2, '')", + params![scope_str, repo], + |row| row.get(0), + ) + .unwrap_or(0); + return Ok(id); + } + + conn.execute( + "INSERT INTO agent_instructions (scope, repo, instruction_text, is_active, updated_by, created_at, updated_at) + VALUES (?1, ?2, ?3, 1, ?4, ?5, ?5)", + params![scope_str, repo, text, updated_by, now], + )?; + Ok(conn.last_insert_rowid()) + } + + /// Get the active agent instruction for a scope, if any. + pub fn get_agent_instruction( + &self, + scope: claudear_core::types::InstructionScope, + repo: Option<&str>, + ) -> Result> { + let conn = self.acquire_lock()?; + let scope_str = scope.to_string(); + let row = conn + .query_row( + "SELECT id, scope, repo, instruction_text, is_active, updated_at + FROM agent_instructions + WHERE scope = ?1 AND IFNULL(repo, '') = IFNULL(?2, '') AND is_active = 1", + params![scope_str, repo], + |row| { + let scope_val: String = row.get(1)?; + Ok(claudear_core::types::AgentInstruction { + id: row.get(0)?, + scope: scope_val + .parse() + .unwrap_or(claudear_core::types::InstructionScope::Global), + repo: row.get(2)?, + instruction_text: row.get(3)?, + is_active: row.get::<_, i32>(4)? != 0, + updated_at: Self::parse_datetime(&row.get::<_, String>(5)?)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + /// Resolve the effective instruction block: global first, then the per-repo + /// override, concatenated with clear provenance. `repo` is None when the run + /// has no resolved repo (e.g. skipped resolution or a QA answer); global + /// instructions still apply in that case. Returns None when neither scope has + /// active text. + pub fn resolve_agent_instructions(&self, repo: Option<&str>) -> Result> { + let global = self + .get_agent_instruction(claudear_core::types::InstructionScope::Global, None)? + .map(|i| i.instruction_text) + .filter(|t| !t.trim().is_empty()); + let per_repo = match repo { + Some(r) => self + .get_agent_instruction(claudear_core::types::InstructionScope::Repo, Some(r))? + .map(|i| i.instruction_text) + .filter(|t| !t.trim().is_empty()), + None => None, + }; + + if global.is_none() && per_repo.is_none() { + return Ok(None); + } + + let mut block = String::from( + "# Operator Instructions (claudear)\nThese instructions were configured by your operators. Follow them.\n", + ); + if let Some(g) = global { + block.push_str("\n## Global\n"); + block.push_str(g.trim()); + block.push('\n'); + } + if let (Some(r), Some(repo)) = (per_repo, repo) { + block.push_str(&format!("\n## Repository: {}\n", repo)); + block.push_str(r.trim()); + block.push('\n'); + } + Ok(Some(block)) + } + /// System 4: Upsert a repo knowledge entry. pub fn upsert_repo_knowledge( &self, @@ -9798,6 +9929,81 @@ mod tests { use super::*; use chrono::{Datelike, Timelike, Utc}; + #[test] + fn test_agent_instructions_scope_and_resolve() { + use claudear_core::types::InstructionScope; + let tracker = SqliteTracker::in_memory().unwrap(); + + // Nothing set: resolve is None. + assert!(tracker + .resolve_agent_instructions(Some("org/repo")) + .unwrap() + .is_none()); + + // Global only. + tracker + .upsert_agent_instruction(InstructionScope::Global, None, "Be terse.", Some("admin")) + .unwrap(); + let g = tracker + .get_agent_instruction(InstructionScope::Global, None) + .unwrap() + .unwrap(); + assert_eq!(g.instruction_text, "Be terse."); + assert_eq!(g.scope, InstructionScope::Global); + assert!(g.repo.is_none()); + + let resolved = tracker + .resolve_agent_instructions(Some("org/repo")) + .unwrap() + .unwrap(); + assert!(resolved.contains("## Global")); + assert!(resolved.contains("Be terse.")); + assert!(!resolved.contains("## Repository:")); + + // Global still applies when there is no resolved repo (e.g. QA / Skip). + let no_repo = tracker.resolve_agent_instructions(None).unwrap().unwrap(); + assert!(no_repo.contains("## Global")); + assert!(!no_repo.contains("## Repository:")); + + // Per-repo override is namespaced and concatenated after global. + tracker + .upsert_agent_instruction( + InstructionScope::Repo, + Some("org/repo"), + "Generated output; edit the generator instead.", + None, + ) + .unwrap(); + let resolved = tracker + .resolve_agent_instructions(Some("org/repo")) + .unwrap() + .unwrap(); + assert!(resolved.contains("## Global")); + assert!(resolved.contains("## Repository: org/repo")); + assert!(resolved.contains("edit the generator instead")); + + // A different repo does not see the override. + let other = tracker + .resolve_agent_instructions(Some("org/other")) + .unwrap() + .unwrap(); + assert!(!other.contains("## Repository:")); + + // No-repo resolution never leaks a per-repo override. + let no_repo = tracker.resolve_agent_instructions(None).unwrap().unwrap(); + assert!(!no_repo.contains("## Repository:")); + + // Upsert replaces text for the same scope (no duplicate row). + tracker + .upsert_agent_instruction(InstructionScope::Global, None, "Updated.", None) + .unwrap(); + let g = tracker + .get_agent_instruction(InstructionScope::Global, None) + .unwrap() + .unwrap(); + assert_eq!(g.instruction_text, "Updated."); + } + #[test] fn test_record_and_retrieve_attempt() { let tracker = SqliteTracker::in_memory().unwrap(); diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 33e321c..2af7eb2 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -1139,6 +1139,30 @@ export async function saveConfig(content: string): Promise<{ ok: boolean; messag } +export interface InstructionResponse { + scope: string + repo: string | null + text: string + updated_at: string | null +} + +export async function fetchGlobalInstruction(): Promise { + return fetchJson(`${API_BASE}/instructions/global`) +} + +export async function saveGlobalInstruction(text: string): Promise<{ ok: boolean }> { + return putJson(`${API_BASE}/instructions/global`, { text }) +} + +export async function fetchRepoInstruction(repo: string): Promise { + return fetchJson(`${API_BASE}/repos/${encodeURIComponent(repo)}/instructions`) +} + +export async function saveRepoInstruction(repo: string, text: string): Promise<{ ok: boolean }> { + return putJson(`${API_BASE}/repos/${encodeURIComponent(repo)}/instructions`, { text }) +} + + export async function login(email: string, password: string): Promise { return postJson(`${API_BASE}/auth/login`, { email, password }) } diff --git a/dashboard/src/pages/config.tsx b/dashboard/src/pages/config.tsx index 8aa50a8..c3ca322 100644 --- a/dashboard/src/pages/config.tsx +++ b/dashboard/src/pages/config.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import useSWR from 'swr' -import { fetchConfig, saveConfig, type ConfigResponse } from '../lib/api' +import { + fetchConfig, saveConfig, type ConfigResponse, + fetchGlobalInstruction, saveGlobalInstruction, type InstructionResponse, +} from '../lib/api' import { PageHeader } from '../components/layout/page-header' import { CardStackSkeleton } from '../components/shared/page-skeletons' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../components/ui/card' @@ -369,6 +372,93 @@ function SectionFormCard({ ) } +function GlobalInstructionsCard() { + const { data, error, isLoading, mutate } = useSWR('global-instruction', fetchGlobalInstruction) + const [draft, setDraft] = useState(null) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [saveError, setSaveError] = useState(null) + + const serverText = data?.text ?? '' + const text = draft ?? serverText + const dirty = text !== serverText + + const handleSave = useCallback(async () => { + const saved = text + setSaving(true) + setSaveError(null) + try { + await saveGlobalInstruction(saved) + } catch (e: any) { + setSaveError(e?.message || 'Failed to save instructions') + return + } finally { + setSaving(false) + } + // PUT persisted. Sync the cache optimistically from the known-saved value so + // a background revalidation failure can't misreport the save or blank the + // editor. Only clear the draft if the user has not typed more while the PUT + // was in flight, so newer edits are not discarded. + setDraft(prev => (prev === saved ? null : prev)) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + mutate(prev => (prev ? { ...prev, text: saved } : prev), { revalidate: false }) + }, [text, mutate]) + + return ( + + +
+ + Global Agent Instructions +
+ + Prepended to the agent's context on every run, for all repos. Set per-repo + overrides from the Repos page. This never overwrites a repo's own AGENTS.md. + +
+ + {error ? ( +
+ + Failed to load instructions: {error.message}. Editing is disabled to avoid overwriting. +
+ ) : ( +