feat(server,kernel): bind ProjectProfile per session and inject as L0 context

This commit is contained in:
2026-06-10 21:49:31 +04:00
parent 4389163c5c
commit 91cca9aee3
4 changed files with 111 additions and 2 deletions
@@ -11,10 +11,12 @@ 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.OperatorProfile 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.ApprovalRequestedEvent
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent import com.correx.core.events.events.NewEvent
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.OrchestrationResumedEvent import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.WorkflowFailedEvent import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.stores.EventStore 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 { runCatching {
orchestrator.run(sessionId, graph, sessionConfig) orchestrator.run(sessionId, graph, sessionConfig)
// After a successful freestyle planning run, lock the plan and execute phase 2. // After a successful freestyle planning run, lock the plan and execute phase 2.
@@ -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,
)
}
@@ -363,9 +363,11 @@ abstract class SessionOrchestrator(
), ),
) )
} ?: emptyList() } ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
var accumulatedEntries = var accumulatedEntries =
systemPrompt + profileEntries + journalEntries + repoMapEntries + needsEntries + systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
schemaEntries + promptEntries + steeringEntries needsEntries + schemaEntries + promptEntries + steeringEntries
val contextPack = contextPackBuilder.build( val contextPack = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()), id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId, sessionId = sessionId,
@@ -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)
}
}