Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1fed7ba
empty commit
ArnabChatterjee20k Aug 7, 2026
1544f56
empty to trigger tests
ArnabChatterjee20k Aug 11, 2026
653592b
feat(db): add agent_instructions table (V10)
ArnabChatterjee20k Aug 16, 2026
01baba8
feat(core): add InstructionScope and AgentInstruction types
ArnabChatterjee20k Aug 16, 2026
db4c48b
feat(storage): upsert/get/resolve agent instructions
ArnabChatterjee20k Aug 16, 2026
879b7f2
feat(engine): inject operator instructions into agent context
ArnabChatterjee20k Aug 16, 2026
611a76f
feat(api): global and per-repo instruction endpoints
ArnabChatterjee20k Aug 16, 2026
7fd2139
feat(dashboard): instruction api client functions
ArnabChatterjee20k Aug 16, 2026
c24bc32
feat(dashboard): global and per-repo instruction editors
ArnabChatterjee20k Aug 16, 2026
a257556
fix(storage): resolve_agent_instructions takes optional repo
ArnabChatterjee20k Aug 16, 2026
5c21cfa
fix(engine): apply operator instructions on QA and no-repo runs
ArnabChatterjee20k Aug 16, 2026
307af9e
fix(dashboard): surface instruction load and save errors
ArnabChatterjee20k Aug 16, 2026
fe6dc66
fix(dashboard): do not misreport save when revalidation fails
ArnabChatterjee20k Aug 16, 2026
6ad97e3
linting
ArnabChatterjee20k Aug 16, 2026
4de7e76
fix(engine): surface operator-instruction lookup failures
ArnabChatterjee20k Aug 16, 2026
e184456
fix(dashboard): preserve edits made while a save is in flight
ArnabChatterjee20k Aug 16, 2026
5707415
fix(dashboard): don't show Saved for unpersisted in-flight edits
ArnabChatterjee20k Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions crates/claudear-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1923,6 +1923,50 @@ pub struct PromotedInstruction {
pub updated_at: DateTime<Utc>,
}

/// 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<Self, Self::Err> {
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<String>,
pub instruction_text: String,
pub is_active: bool,
pub updated_at: DateTime<Utc>,
}

/// Per-repo accumulated knowledge entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoKnowledge {
Expand Down
134 changes: 134 additions & 0 deletions crates/claudear-engine/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2732,6 +2740,132 @@ async fn put_config_handler(
))
}

#[derive(Serialize)]
struct InstructionResponse {
scope: String,
repo: Option<String>,
text: String,
updated_at: Option<String>,
}

#[derive(Deserialize)]
struct InstructionUpdateRequest {
text: String,
}

fn instruction_response(
scope: claudear_core::types::InstructionScope,
repo: Option<String>,
instruction: Option<claudear_core::types::AgentInstruction>,
) -> 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<ApiState>,
) -> Result<Json<InstructionResponse>, 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<ApiState>,
Json(body): Json<InstructionUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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<ApiState>,
Path(repo): Path<String>,
) -> Result<Json<InstructionResponse>, 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<ApiState>,
Path(repo): Path<String>,
Json(body): Json<InstructionUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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,
Expand Down
40 changes: 40 additions & 0 deletions crates/claudear-engine/src/processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
26 changes: 26 additions & 0 deletions crates/claudear-storage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64> {
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<Option<claudear_core::types::AgentInstruction>> {
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<Option<String>> {
Ok(None)
}

/// System 4: Upsert a repo knowledge entry.
fn upsert_repo_knowledge(&self, _entry: &claudear_core::types::RepoKnowledge) -> Result<i64> {
Ok(0)
Expand Down
19 changes: 17 additions & 2 deletions crates/claudear-storage/src/migrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -185,7 +200,7 @@ mod tests {
row.get(0)
})
.unwrap();
assert_eq!(version, 9);
assert_eq!(version, 10);
}

#[test]
Expand Down
Loading
Loading