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 f4197f4b..c4654dfa 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,10 +11,12 @@ import com.correx.core.approvals.DefaultApprovalReducer import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.artifactstore.ArtifactStore import com.correx.core.config.OperatorProfile +import com.correx.core.config.ProjectProfileLoader import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.NewEvent import com.correx.core.events.events.OperatorProfileBoundEvent +import com.correx.core.events.events.ProjectProfileBoundEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.stores.EventStore @@ -217,6 +219,35 @@ class ServerModule( ), ) } + // Bind the per-repo project profile (curated .correx/project.toml) as an event so + // stages and replay read the recorded snapshot, never the live file (invariants #8/#9). + val workspaceRoot = runCatching { + sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot + }.getOrNull() ?: projectMemory?.repoRoot() + workspaceRoot?.let { root -> + val projectProfile = ProjectProfileLoader.load(root) + if (!projectProfile.isEmpty()) { + 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 = ProjectProfileBoundEvent( + sessionId = sessionId, + workspaceRoot = root, + about = projectProfile.about, + conventions = projectProfile.conventions, + commands = projectProfile.commands, + ), + ), + ) + } + } runCatching { orchestrator.run(sessionId, graph, sessionConfig) // After a successful freestyle planning run, lock the plan and execute phase 2. 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 new file mode 100644 index 00000000..6faddc86 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -0,0 +1,35 @@ +package com.correx.core.kernel.orchestration + +// Context-entry builders kept top-level (not orchestrator members) for unit-testability +// and to stay under detekt's function-count threshold (same pattern as isBackEdge). + +import com.correx.core.context.model.ContextEntry +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.EntryRole +import com.correx.core.events.types.ContextEntryId +import com.correx.core.sessions.BoundProjectProfile +import java.util.UUID + +fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { + val content = buildString { + append("## Project profile\n") + if (profile.about.isNotBlank()) append(profile.about).append("\n") + if (profile.conventions.isNotEmpty()) { + append("### Conventions\n") + profile.conventions.forEach { append("- ").append(it).append("\n") } + } + if (profile.commands.isNotEmpty()) { + append("### Commands\n") + profile.commands.forEach { (name, cmd) -> append(name).append(": ").append(cmd).append("\n") } + } + }.trimEnd() + return ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L0, + content = content, + sourceType = "projectProfile", + sourceId = "project-profile", + 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 5f1f974d..657b4172 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 @@ -363,9 +363,11 @@ abstract class SessionOrchestrator( ), ) } ?: emptyList() + val projectProfileEntries = session.state.boundProjectProfile + ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() var accumulatedEntries = - systemPrompt + profileEntries + journalEntries + repoMapEntries + needsEntries + - schemaEntries + promptEntries + steeringEntries + systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + + needsEntries + schemaEntries + promptEntries + steeringEntries val contextPack = contextPackBuilder.build( id = ContextPackId(UUID.randomUUID().toString()), sessionId = sessionId, diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt new file mode 100644 index 00000000..e059a75d --- /dev/null +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -0,0 +1,41 @@ +import com.correx.core.context.model.ContextLayer +import com.correx.core.context.model.EntryRole +import com.correx.core.kernel.orchestration.buildProjectProfileEntry +import com.correx.core.sessions.BoundProjectProfile +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ContextFeedbackTest { + + @Test + fun `project profile renders as single L0 entry`() { + val entry = buildProjectProfileEntry( + BoundProjectProfile( + about = "Event-sourced kernel", + conventions = listOf("no bare try-catch"), + commands = mapOf("test" to "./gradlew check"), + ), + ) + assertEquals(ContextLayer.L0, entry.layer) + assertEquals(EntryRole.SYSTEM, entry.role) + assertTrue(entry.content.startsWith("## Project profile"), "content: ${entry.content}") + assertTrue(entry.content.contains("- no bare try-catch"), "content: ${entry.content}") + assertTrue(entry.content.contains("test: ./gradlew check"), "content: ${entry.content}") + } + + @Test + fun `project profile with only about renders without conventions or commands sections`() { + val entry = buildProjectProfileEntry( + BoundProjectProfile( + about = "Simple project", + conventions = emptyList(), + commands = emptyMap(), + ), + ) + assertTrue(entry.content.contains("Simple project"), "content: ${entry.content}") + assertTrue(!entry.content.contains("### Conventions"), "should not contain Conventions: ${entry.content}") + assertTrue(!entry.content.contains("### Commands"), "should not contain Commands: ${entry.content}") + assertEquals("projectProfile", entry.sourceType) + } +}