diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 269395a5..5a794926 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -11,9 +11,11 @@ import com.correx.core.approvals.ApprovalProjector import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.artifactstore.ArtifactStore +import com.correx.core.config.AgentInstructionsLoader import com.correx.core.config.OperatorProfile import com.correx.core.config.ProjectProfileLoader 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.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent @@ -322,6 +324,7 @@ class ServerModule( ) } bindProjectProfile(sessionId) + bindAgentInstructions(sessionId) runCatching { val result = orchestrator.run(sessionId, graph, sessionConfig) 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 * planning graph (fresh run and all resume paths). Locks the plan and executes it, diff --git a/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt new file mode 100644 index 00000000..f0358778 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructions.kt @@ -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 = emptyList(), + val content: String = "", +) { + fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank() +} diff --git a/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt new file mode 100644 index 00000000..26acf784 --- /dev/null +++ b/core/config/src/main/kotlin/com/correx/core/config/AgentInstructionsLoader.kt @@ -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() + val sources = mutableListOf() + 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 + } + } +} diff --git a/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt new file mode 100644 index 00000000..5a66e986 --- /dev/null +++ b/core/config/src/test/kotlin/com/correx/core/config/AgentInstructionsLoaderTest.kt @@ -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) + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt index da552b48..5dbefe81 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/events/SessionEvents.kt @@ -37,3 +37,12 @@ data class ProjectProfileBoundEvent( val conventions: List, val commands: Map, ) : EventPayload + +@Serializable +@SerialName("AgentInstructionsBound") +data class AgentInstructionsBoundEvent( + val sessionId: SessionId, + val workspaceRoot: String, + val sources: List, // file names found, e.g. ["CLAUDE.md","AGENTS.md"] + val content: String, // concatenated, header-labeled instructions +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 64a985ae..fc752c38 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt index 58627b9a..06367684 100644 --- a/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/ProjectProfileBoundEventSerializationTest.kt @@ -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( diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index 5fcab52e..4557a00d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -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, + ) +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index e0960b82..7be575a9 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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, diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt new file mode 100644 index 00000000..cb5db1a0 --- /dev/null +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/BoundAgentInstructions.kt @@ -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, + val content: String, +) diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt index d6622dbd..32ad5e84 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/DefaultSessionReducer.kt @@ -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, ) } } diff --git a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt index d2d21f48..4d28ef3a 100644 --- a/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt +++ b/core/sessions/src/main/kotlin/com/correx/core/sessions/SessionState.kt @@ -10,4 +10,5 @@ data class SessionState( val boundWorkspace: BoundWorkspace? = null, val boundProfile: BoundProfile? = null, val boundProjectProfile: BoundProjectProfile? = null, + val boundAgentInstructions: BoundAgentInstructions? = null, ) diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index 4de53ce9..85b9ccae 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -11,11 +11,13 @@ import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId 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.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildRelevantFilesEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.criticArtifactIds +import com.correx.core.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.transitions.graph.StageConfig import com.correx.core.transitions.graph.TransitionEdge @@ -87,6 +89,21 @@ class ContextFeedbackTest { 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 fun `criticArtifactIds resolves the from-stage produces on a back-edge`() { val graph = graphWith( diff --git a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt index 878d367c..0b4ac707 100644 --- a/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt +++ b/testing/projections/src/test/kotlin/DefaultSessionReducerTest.kt @@ -10,7 +10,9 @@ import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId 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.sessions.BoundAgentInstructions import com.correx.core.sessions.BoundProjectProfile import com.correx.core.sessions.BoundWorkspace import com.correx.core.sessions.DefaultSessionReducer @@ -327,6 +329,50 @@ class DefaultSessionReducerTest { 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() = SessionState( status = SessionStatus.CREATED