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
@@ -11,9 +11,11 @@ import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.config.AgentInstructionsLoader
import com.correx.core.config.OperatorProfile import com.correx.core.config.OperatorProfile
import com.correx.core.config.ProjectProfileLoader import com.correx.core.config.ProjectProfileLoader
import com.correx.apps.server.memory.ArchitectContradictionChecker import com.correx.apps.server.memory.ArchitectContradictionChecker
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ArtifactCreatedEvent
@@ -322,6 +324,7 @@ class ServerModule(
) )
} }
bindProjectProfile(sessionId) bindProjectProfile(sessionId)
bindAgentInstructions(sessionId)
runCatching { runCatching {
val result = orchestrator.run(sessionId, graph, sessionConfig) val result = orchestrator.run(sessionId, graph, sessionConfig)
freestyleHandoff(sessionId, graph, result) freestyleHandoff(sessionId, graph, result)
@@ -405,6 +408,37 @@ class ServerModule(
) )
} }
/**
* Bind standing agent instructions (CLAUDE.md / AGENTS.md at the workspace root) as an
* event so stages, router chat triage, and replay read the recorded snapshot, never the
* live file (invariants #8/#9). Mirrors [bindProjectProfile].
*/
suspend fun bindAgentInstructions(sessionId: SessionId) {
val workspaceRoot = runCatching {
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
if (instructions.isEmpty()) return
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(java.util.UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = AgentInstructionsBoundEvent(
sessionId = sessionId,
workspaceRoot = workspaceRoot,
sources = instructions.sources,
content = instructions.content,
),
),
)
}
/** /**
* Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a * Phase-2 handoff for freestyle sessions, shared by every launcher that can finish a
* planning graph (fresh run and all resume paths). Locks the plan and executes it, * planning graph (fresh run and all resume paths). Locks the plan and executes it,
@@ -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 conventions: List<String>,
val commands: Map<String, String>, val commands: Map<String, String>,
) : EventPayload ) : 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 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.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalGrantCreatedEvent import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ApprovalGrantExpiredEvent import com.correx.core.events.events.ApprovalGrantExpiredEvent
@@ -126,6 +127,7 @@ val eventModule = SerializersModule {
subclass(SessionWorkspaceBoundEvent::class) subclass(SessionWorkspaceBoundEvent::class)
subclass(OperatorProfileBoundEvent::class) subclass(OperatorProfileBoundEvent::class)
subclass(ProjectProfileBoundEvent::class) subclass(ProjectProfileBoundEvent::class)
subclass(AgentInstructionsBoundEvent::class)
subclass(L3MemoryRetrievedEvent::class) subclass(L3MemoryRetrievedEvent::class)
subclass(ContextTruncatedEvent::class) subclass(ContextTruncatedEvent::class)
subclass(ExecutionPlanLockedEvent::class) subclass(ExecutionPlanLockedEvent::class)
@@ -1,5 +1,6 @@
package com.correx.core.events.serialization 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.EventPayload
import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
@@ -24,6 +25,20 @@ class ProjectProfileBoundEventSerializationTest {
assertEquals(sample, decoded) 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 @Test
fun `round-trips with empty fields`() { fun `round-trips with empty fields`() {
val sample: EventPayload = ProjectProfileBoundEvent( 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.ArtifactId
import com.correx.core.events.types.ContextEntryId import com.correx.core.events.types.ContextEntryId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.sessions.BoundAgentInstructions
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.WorkflowGraph import com.correx.core.transitions.graph.WorkflowGraph
import java.util.UUID import java.util.UUID
@@ -153,3 +154,17 @@ fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
role = EntryRole.SYSTEM, 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() } ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val agentInstructionsEntries = session.state.boundAgentInstructions
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId) val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList() ?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry val vocabularyEntries = artifactKindRegistry
@@ -411,7 +413,8 @@ abstract class SessionOrchestrator(
.mapNotNull { it.payload as? StaticFindingsRecordedEvent } .mapNotNull { it.payload as? StaticFindingsRecordedEvent }
.flatMap { it.findings } .flatMap { it.findings }
var accumulatedEntries = excludeStaticFindingsFromReview( var accumulatedEntries = excludeStaticFindingsFromReview(
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + systemPrompt + profileEntries + projectProfileEntries + agentInstructionsEntries +
journalEntries + repoMapEntries +
needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries +
clarificationEntries + retryFeedbackEntries, clarificationEntries + retryFeedbackEntries,
recordedStaticFindings, 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 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.OperatorProfileBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent import com.correx.core.events.events.SessionWorkspaceBoundEvent
@@ -61,6 +62,14 @@ class DefaultSessionReducer : SessionReducer {
else -> state.boundProjectProfile else -> state.boundProjectProfile
} }
val boundAgentInstructions = when (payload) {
is AgentInstructionsBoundEvent -> BoundAgentInstructions(
sources = payload.sources,
content = payload.content,
)
else -> state.boundAgentInstructions
}
return state.copy( return state.copy(
status = newStatus, status = newStatus,
createdAt = createdAt, createdAt = createdAt,
@@ -68,6 +77,7 @@ class DefaultSessionReducer : SessionReducer {
boundWorkspace = boundWorkspace, boundWorkspace = boundWorkspace,
boundProfile = boundProfile, boundProfile = boundProfile,
boundProjectProfile = boundProjectProfile, boundProjectProfile = boundProjectProfile,
boundAgentInstructions = boundAgentInstructions,
) )
} }
} }
@@ -10,4 +10,5 @@ data class SessionState(
val boundWorkspace: BoundWorkspace? = null, val boundWorkspace: BoundWorkspace? = null,
val boundProfile: BoundProfile? = null, val boundProfile: BoundProfile? = null,
val boundProjectProfile: BoundProjectProfile? = null, val boundProjectProfile: BoundProjectProfile? = null,
val boundAgentInstructions: BoundAgentInstructions? = null,
) )
@@ -11,11 +11,13 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId import com.correx.core.events.types.TransitionId
import com.correx.core.events.events.RepoKnowledgeHit import com.correx.core.events.events.RepoKnowledgeHit
import com.correx.core.kernel.orchestration.buildAgentInstructionsEntry
import com.correx.core.kernel.orchestration.buildArtifactKindVocabularyEntry import com.correx.core.kernel.orchestration.buildArtifactKindVocabularyEntry
import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry
import com.correx.core.kernel.orchestration.buildRelevantFilesEntry import com.correx.core.kernel.orchestration.buildRelevantFilesEntry
import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry
import com.correx.core.kernel.orchestration.criticArtifactIds import com.correx.core.kernel.orchestration.criticArtifactIds
import com.correx.core.sessions.BoundAgentInstructions
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.transitions.graph.StageConfig import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge import com.correx.core.transitions.graph.TransitionEdge
@@ -87,6 +89,21 @@ class ContextFeedbackTest {
assertEquals("projectProfile", entry.sourceType) assertEquals("projectProfile", entry.sourceType)
} }
@Test
fun `agent instructions render as single L0 entry`() {
val entry = buildAgentInstructionsEntry(
BoundAgentInstructions(
sources = listOf("CLAUDE.md", "AGENTS.md"),
content = "# CLAUDE.md\n\nbe careful\n\n# AGENTS.md\n\nuse tools",
),
)
assertEquals(ContextLayer.L0, entry.layer)
assertEquals(EntryRole.SYSTEM, entry.role)
assertEquals("agentInstructions", entry.sourceType)
assertTrue(entry.content.contains("# CLAUDE.md"), "content: ${entry.content}")
assertTrue(entry.content.contains("# AGENTS.md"), "content: ${entry.content}")
}
@Test @Test
fun `criticArtifactIds resolves the from-stage produces on a back-edge`() { fun `criticArtifactIds resolves the from-stage produces on a back-edge`() {
val graph = graphWith( val graph = graphWith(
@@ -10,7 +10,9 @@ import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId import com.correx.core.events.types.TransitionId
import com.correx.core.events.events.AgentInstructionsBoundEvent
import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.ProjectProfileBoundEvent
import com.correx.core.sessions.BoundAgentInstructions
import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundProjectProfile
import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.BoundWorkspace
import com.correx.core.sessions.DefaultSessionReducer import com.correx.core.sessions.DefaultSessionReducer
@@ -327,6 +329,50 @@ class DefaultSessionReducerTest {
assertEquals("kernel project", result.boundProjectProfile?.about) assertEquals("kernel project", result.boundProjectProfile?.about)
} }
@Test
fun `AgentInstructionsBoundEvent reduces into boundAgentInstructions`() {
val result = reducer.reduce(
state = initialState(),
event = stored(
sessionId = sessionId,
payload = AgentInstructionsBoundEvent(
sessionId = sessionId,
workspaceRoot = "/repo",
sources = listOf("CLAUDE.md", "AGENTS.md"),
content = "# CLAUDE.md\n\nbe careful",
),
),
)
assertEquals(
BoundAgentInstructions(
sources = listOf("CLAUDE.md", "AGENTS.md"),
content = "# CLAUDE.md\n\nbe careful",
),
result.boundAgentInstructions,
)
}
@Test
fun `boundAgentInstructions is preserved by subsequent unrelated events`() {
val withInstructions = initialState().copy(
boundAgentInstructions = BoundAgentInstructions(
sources = listOf("CLAUDE.md"),
content = "# CLAUDE.md\n\nrules",
),
)
val result = reducer.reduce(
state = withInstructions,
event = stored(
sessionId = sessionId,
payload = WorkflowStartedEvent(sessionId, workflowId = "wf", startStageId = StageId("s1")),
),
)
assertEquals(listOf("CLAUDE.md"), result.boundAgentInstructions?.sources)
}
private fun initialState() = private fun initialState() =
SessionState( SessionState(
status = SessionStatus.CREATED status = SessionStatus.CREATED