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)
}
}