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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user