feat(context): inject CLAUDE.md / AGENTS.md as L0 standing context

On session start, discover CLAUDE.md and AGENTS.md at the bound workspace root and inject
their (concatenated, header-labeled) contents as an L0 system context entry for every stage —
mirroring the .correx/project.toml ProjectProfile path end to end. Recorded as
AgentInstructionsBoundEvent (invariant #9) so replay reads the recorded fact, not the live
file; folded into SessionState.boundAgentInstructions and rendered by buildAgentInstructionsEntry
right after the project profile. AgentInstructionsLoader reads root-only, both files if present.
Bound via ServerModule.bindAgentInstructions alongside bindProjectProfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 10:45:43 +00:00
parent 04d7e26482
commit c616982b7b
14 changed files with 269 additions and 1 deletions
@@ -0,0 +1,17 @@
package com.correx.core.config
import kotlinx.serialization.Serializable
/**
* Standing agent instructions discovered at the bound workspace root (`CLAUDE.md` and/or
* `AGENTS.md`). Unlike the curated `.correx/project.toml` ProjectProfile, these are the
* free-form markdown briefs the operator already maintains for coding agents: injected as
* L0 for every stage of every session bound to the workspace.
*/
@Serializable
data class AgentInstructions(
val sources: List<String> = emptyList(),
val content: String = "",
) {
fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank()
}
@@ -0,0 +1,35 @@
package com.correx.core.config
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
/**
* Discovers standing agent instructions (`CLAUDE.md`, `AGENTS.md`) at the bound workspace
* root only — no parent walk, no nested lookup. Mirrors [ProjectProfileLoader]: the loaded
* snapshot is bound as an event so replay reads the recorded fact, never the live file.
*/
object AgentInstructionsLoader {
private val FILE_NAMES = listOf("CLAUDE.md", "AGENTS.md")
fun load(workspaceRoot: String): AgentInstructions {
val sections = mutableListOf<String>()
val sources = mutableListOf<String>()
for (name in FILE_NAMES) {
val contents = readIfPresent(Paths.get(workspaceRoot, name))
if (contents == null || contents.isBlank()) continue
sources.add(name)
sections.add("# $name\n\n${contents.trim()}")
}
if (sections.isEmpty()) return AgentInstructions()
return AgentInstructions(sources = sources, content = sections.joinToString("\n\n"))
}
private fun readIfPresent(path: Path): String? {
if (!Files.exists(path)) return null
return runCatching { Files.readString(path) }.getOrElse { e ->
System.err.println("Warning: Failed to read agent instructions at $path: ${e.message}")
null
}
}
}
@@ -0,0 +1,57 @@
package com.correx.core.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
class AgentInstructionsLoaderTest {
@Test
fun `both files present yields both sections and sources`(@TempDir root: Path) {
Files.writeString(root.resolve("CLAUDE.md"), "Claude rules")
Files.writeString(root.resolve("AGENTS.md"), "Agents rules")
val loaded = AgentInstructionsLoader.load(root.toString())
assertEquals(listOf("CLAUDE.md", "AGENTS.md"), loaded.sources)
assertTrue(loaded.content.contains("# CLAUDE.md"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("Claude rules"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("Agents rules"), "content: ${loaded.content}")
assertFalse(loaded.isEmpty())
}
@Test
fun `only one file present yields that one`(@TempDir root: Path) {
Files.writeString(root.resolve("AGENTS.md"), "Agents only")
val loaded = AgentInstructionsLoader.load(root.toString())
assertEquals(listOf("AGENTS.md"), loaded.sources)
assertTrue(loaded.content.contains("# AGENTS.md"), "content: ${loaded.content}")
assertTrue(loaded.content.contains("Agents only"), "content: ${loaded.content}")
assertFalse(loaded.content.contains("CLAUDE.md"), "content: ${loaded.content}")
}
@Test
fun `neither file present is empty`(@TempDir root: Path) {
val loaded = AgentInstructionsLoader.load(root.toString())
assertTrue(loaded.isEmpty())
assertEquals(AgentInstructions(), loaded)
}
@Test
fun `blank file is skipped`(@TempDir root: Path) {
Files.writeString(root.resolve("CLAUDE.md"), " \n ")
Files.writeString(root.resolve("AGENTS.md"), "Real content")
val loaded = AgentInstructionsLoader.load(root.toString())
assertEquals(listOf("AGENTS.md"), loaded.sources)
}
}
@@ -37,3 +37,12 @@ data class ProjectProfileBoundEvent(
val conventions: List<String>,
val commands: Map<String, String>,
) : EventPayload
@Serializable
@SerialName("AgentInstructionsBound")
data class AgentInstructionsBoundEvent(
val sessionId: SessionId,
val workspaceRoot: String,
val sources: List<String>, // file names found, e.g. ["CLAUDE.md","AGENTS.md"]
val content: String, // concatenated, header-labeled instructions
) : EventPayload
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent
@@ -126,6 +127,7 @@ val eventModule = SerializersModule {
subclass(SessionWorkspaceBoundEvent::class)
subclass(OperatorProfileBoundEvent::class)
subclass(ProjectProfileBoundEvent::class)
subclass(AgentInstructionsBoundEvent::class)
subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class)
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.types.SessionId
@@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest {
assertEquals(sample, decoded)
}
@Test
fun `AgentInstructionsBoundEvent round-trips as polymorphic EventPayload`() {
val sample: EventPayload = AgentInstructionsBoundEvent(
sessionId = SessionId("s1"),
workspaceRoot = "/repo",
sources = listOf("CLAUDE.md", "AGENTS.md"),
content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools",
)
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"AgentInstructionsBound\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `round-trips with empty fields`() {
val sample: EventPayload = ProjectProfileBoundEvent(
@@ -15,6 +15,7 @@ import com.correx.core.events.events.StoredEvent
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.StageId
import com.correx.core.sessions.BoundAgentInstructions
import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.WorkflowGraph
import java.util.UUID
@@ -153,3 +154,17 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
role = EntryRole.SYSTEM,
)
}
/** Renders bound CLAUDE.md / AGENTS.md instructions as a single L0 system entry. */
fun buildAgentInstructionsEntry(instructions: BoundAgentInstructions): ContextEntry {
val content = instructions.content
return ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L0,
content = content,
sourceType = "agentInstructions",
sourceId = "agent-instructions",
tokenEstimate = content.length / 4,
role = EntryRole.SYSTEM,
)
}
@@ -399,6 +399,8 @@ abstract class SessionOrchestrator(
} ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val agentInstructionsEntries = session.state.boundAgentInstructions
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry
@@ -411,7 +413,8 @@ abstract class SessionOrchestrator(
.mapNotNull { it.payload as? StaticFindingsRecordedEvent }
.flatMap { it.findings }
var accumulatedEntries = excludeStaticFindingsFromReview(
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries,
recordedStaticFindings,
@@ -0,0 +1,7 @@
package com.correx.core.sessions
/** Snapshot of standing agent instructions (CLAUDE.md / AGENTS.md) bound at session start. */
data class BoundAgentInstructions(
val sources: List<String>,
val content: String,
)
@@ -1,5 +1,6 @@
package com.correx.core.sessions
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
@@ -61,6 +62,14 @@ class DefaultSessionReducer : SessionReducer {
else -> state.boundProjectProfile
}
val boundAgentInstructions = when (payload) {
is AgentInstructionsBoundEvent -> BoundAgentInstructions(
sources = payload.sources,
content = payload.content,
)
else -> state.boundAgentInstructions
}
return state.copy(
status = newStatus,
createdAt = createdAt,
@@ -68,6 +77,7 @@ class DefaultSessionReducer : SessionReducer {
boundWorkspace = boundWorkspace,
boundProfile = boundProfile,
boundProjectProfile = boundProjectProfile,
boundAgentInstructions = boundAgentInstructions,
)
}
}
@@ -10,4 +10,5 @@ data class SessionState(
val boundWorkspace: BoundWorkspace? = null,
val boundProfile: BoundProfile? = null,
val boundProjectProfile: BoundProjectProfile? = null,
val boundAgentInstructions: BoundAgentInstructions? = null,
)