3 Commits

33 changed files with 220 additions and 81 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ All sources under `apps/server/src/`.
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
- At boot, `tools.workspace_root` is the authoritative tool jail and project-observation root. A configured `tools.working_dir` may only remain distinct when it is contained by that root; an outside value is clamped to `workspace_root`. Project memory and repo-map indexing are also rebound to `workspace_root`, so a stale `[project].root` cannot inject files from outside the session workspace.
- At boot, `tools.workspace_root` is the authoritative default tool jail. Every session records its own resolved workspace binding; repo maps, project memory, profile/instruction snapshots, and git run branches use that binding and skip unbound sessions. `[project]` never supplies a workspace root.
### WebSocket protocol (`/ws`)
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
@@ -1,6 +1,5 @@
package com.correx.apps.server
import com.correx.core.config.ProjectConfig
import java.nio.file.Path
internal data class BootWorkspace(
@@ -28,8 +27,3 @@ internal fun resolveBootWorkspace(
workingDirWasClamped = !workingDirIsContained,
)
}
/** Keep repo-map observation and L3 project memory inside the authoritative boot workspace. */
internal fun ProjectConfig.boundToWorkspace(workspaceRoot: Path): ProjectConfig = copy(
root = workspaceRoot.toAbsolutePath().normalize().toString(),
)
@@ -590,7 +590,7 @@ fun main() {
fun buildProjectMemory(cfg: CorrexConfig): com.correx.apps.server.memory.ProjectMemoryService? =
if (cfg.project.enabled) {
com.correx.apps.server.memory.ProjectMemoryService(
config = cfg.project.boundToWorkspace(workspaceRoot),
config = cfg.project,
embedder = embedder,
l3MemoryStore = l3MemoryStore,
journalRepository = decisionJournalRepository,
@@ -278,7 +278,8 @@ class ServerModule(
event: ArtifactCreatedEvent,
): PossibleContradictionFlaggedEvent? {
val decisionText = resolveArchitectDecisionText(event) ?: return null
val flag = checker.check(event.sessionId, event.stageId, decisionText) ?: return null
val workspaceRoot = sessionWorkspaceRoot(event.sessionId) ?: return null
val flag = checker.check(event.sessionId, event.stageId, decisionText, workspaceRoot) ?: return null
eventStore.append(
NewEvent(
metadata = EventMetadata(
@@ -379,12 +380,13 @@ class ServerModule(
withSessionContext(sessionId) {
// Record the repo map + seed prior-session memory before the run so stages
// see both in context.
projectMemory?.let { pm ->
val root = sessionWorkspaceRoot(sessionId)
runCatching {
pm.observeAndRecord(sessionId, root)
pm.indexAndRecord(sessionId, root)
pm.retrieveAndSeed(sessionId, root)
sessionWorkspaceRoot(sessionId)?.let { root ->
projectMemory?.let { pm ->
runCatching {
pm.observeAndRecord(sessionId, root)
pm.indexAndRecord(sessionId, root)
pm.retrieveAndSeed(sessionId, root)
}
}
}
// Bind operator profile snapshot as an event so replay reads the recorded
@@ -417,7 +419,9 @@ class ServerModule(
val result = orchestrator.run(sessionId, graph, sessionConfig)
freestyleHandoff(sessionId, graph, result)
// Distil this run's decisions into durable project memory on completion.
projectMemory?.let { pm -> pm.persist(sessionId, sessionWorkspaceRoot(sessionId)) }
sessionWorkspaceRoot(sessionId)?.let { root ->
projectMemory?.let { pm -> pm.persist(sessionId, root) }
}
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
operatorProfile?.let { profile ->
profileAdaptationService?.let { svc ->
@@ -426,7 +430,7 @@ class ServerModule(
}
}
}
val workspaceRoot = sessionConfig.workspace?.workspaceRoot
val workspaceRoot = sessionWorkspaceRoot(sessionId)?.let(java.nio.file.Path::of)
if (gitRunBranchTransport != null && workspaceRoot != null) {
gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
} else {
@@ -501,19 +505,15 @@ class ServerModule(
*/
/**
* The session's bound workspace root — the same one the tool jail and [SessionWorkspaceBoundEvent]
* use. The repo-map/index/L3-memory pipeline MUST key off this, not [ProjectMemoryService.repoRoot]
* (a session-independent config/cwd default): when they diverge the repo map is computed for a
* different tree than the session operates in, so grounding "proves" real paths absent
* (session 5fe538f5, 2026-07-19). Falls back to the server default only when unbound.
* use. Repo-scoped work must skip unbound sessions: a server cwd is not a session fact and
* cannot safely stand in for the recorded workspace binding.
*/
private fun sessionWorkspaceRoot(sessionId: SessionId): String =
private fun sessionWorkspaceRoot(sessionId: SessionId): String? =
runCatching { sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot }
.getOrNull() ?: projectMemory?.repoRoot() ?: "."
.getOrNull()
suspend fun bindProjectProfile(sessionId: SessionId) {
val workspaceRoot = runCatching {
sessionRepository.getSession(sessionId).state.boundWorkspace?.workspaceRoot
}.getOrNull() ?: projectMemory?.repoRoot() ?: return
val workspaceRoot = sessionWorkspaceRoot(sessionId) ?: return
val projectProfile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
if (projectProfile.isEmpty()) return
eventStore.append(
@@ -543,9 +543,7 @@ class ServerModule(
* 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 workspaceRoot = sessionWorkspaceRoot(sessionId) ?: return
val instructions = withContext(Dispatchers.IO) { AgentInstructionsLoader.load(workspaceRoot) }
if (instructions.isEmpty()) return
eventStore.append(
@@ -16,18 +16,16 @@ import com.correx.core.talkie.l3.L3Query
* emits the flag without ever halting or failing the stage.
*
* Namespace convention: distilled decision-journal lines are persisted into L3 by
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
* [L3RepoKnowledgeRetriever]'s versioned `"repomap:v2:<repoRoot>:"` filter. Hits are also constrained to PRIOR
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
* [ProjectMemoryService] under `turnId = "project:<workspaceRoot>"`. The exact tag is derived
* from the session's recorded workspace binding so a decision can never cross workspace boundaries.
* Hits are also constrained to PRIOR sessions (`entry.sessionId != sessionId`) so the architect
* never flags its own in-flight run.
*/
class ArchitectContradictionChecker(
private val embedder: Embedder,
private val l3MemoryStore: L3MemoryStore,
private val k: Int = DEFAULT_K,
private val scoreThreshold: Double = DEFAULT_SCORE_THRESHOLD,
private val decisionNamespacePrefix: String = DEFAULT_DECISION_NAMESPACE_PREFIX,
) {
/**
* @return a [PossibleContradictionFlaggedEvent] listing related prior decisions, or null when
@@ -37,11 +35,12 @@ class ArchitectContradictionChecker(
sessionId: SessionId,
stageId: StageId,
decisionText: String,
workspaceRoot: String,
): PossibleContradictionFlaggedEvent? {
if (decisionText.isBlank()) return null
val vector = embedder.embed(decisionText)
val related = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
.filter { it.entry.turnId.startsWith(decisionNamespacePrefix) }
.filter { it.entry.turnId == projectMemoryTag(workspaceRoot) }
.filter { it.entry.sessionId != sessionId }
.filter { it.score >= scoreThreshold }
.take(k)
@@ -64,7 +63,8 @@ class ArchitectContradictionChecker(
companion object {
const val DEFAULT_K = 5
const val DEFAULT_SCORE_THRESHOLD = 0.75
const val DEFAULT_DECISION_NAMESPACE_PREFIX = "project:"
private const val RETRIEVAL_OVERSAMPLE_FACTOR = 4
fun projectMemoryTag(workspaceRoot: String): String = "project:$workspaceRoot"
}
}
@@ -52,9 +52,6 @@ class ProjectMemoryService(
private fun tag(repoRoot: String) = "project:$repoRoot"
/** Resolved repo-root key: configured [ProjectConfig.root], else the working dir. */
fun repoRoot(): String = config.root.ifBlank { System.getProperty("user.dir") ?: "." }
/**
* Walk [repoRoot] and record the ranked file/symbol map as a [RepoMapComputedEvent] once
* per session. The full map is recorded (the log); only a top-K slice is injected into
@@ -15,6 +15,7 @@ import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.PossibleContradictionFlaggedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId
@@ -120,6 +121,11 @@ class ArchitectContradictionHookTest {
score = score,
)
private fun bindWorkspace(es: EventStore) = append(
es,
SessionWorkspaceBoundEvent(session, workspaceRoot = "/repo", allowedPaths = listOf("/repo")),
)
private fun flagsIn(es: EventStore): List<PossibleContradictionFlaggedEvent> =
es.read(session).map { it.payload }.filterIsInstance<PossibleContradictionFlaggedEvent>()
@@ -219,6 +225,7 @@ class ArchitectContradictionHookTest {
val es = InMemoryEventStore()
val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}"""
val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray()))
bindWorkspace(es)
// ArtifactContentStored precedes ArtifactCreated for the same artifactId (inference-time).
append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage))
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
@@ -247,6 +254,7 @@ class ArchitectContradictionHookTest {
val es = InMemoryEventStore()
val designJson = """{"approach":"Use Postgres for the event store.","components":["db.kt"]}"""
val artifacts = MapArtifactStore(mapOf(contentHash.value to designJson.toByteArray()))
bindWorkspace(es)
append(es, ArtifactContentStoredEvent(designArtifact, contentHash, session, architectStage))
val created = ArtifactCreatedEvent(designArtifact, session, architectStage, schemaVersion = 1)
append(es, created)
@@ -1,6 +1,5 @@
package com.correx.apps.server
import com.correx.core.config.ProjectConfig
import java.nio.file.Path
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -47,15 +46,4 @@ class BootWorkspaceTest {
assertFalse(resolved.workingDirWasClamped)
}
@Test
fun `project memory root follows the authoritative workspace root`() {
val configured = ProjectConfig(
enabled = true,
root = "/home/user/repo",
)
val resolved = configured.boundToWorkspace(Path.of("/tmp/audition/../audition"))
assertEquals("/tmp/audition", resolved.root)
}
}
@@ -52,7 +52,7 @@ class ArchitectContradictionCheckerTest {
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
val flag = checker.check(newSession, stageId, "Use Postgres for the event store.")
val flag = checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo")
assertTrue(flag != null, "expected a flag")
assertEquals(newSession, flag!!.sessionId)
@@ -69,7 +69,7 @@ class ArchitectContradictionCheckerTest {
fun `returns null when there are no hits`() = runBlocking {
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), CannedL3MemoryStore(emptyList()))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
}
@Test
@@ -79,7 +79,7 @@ class ArchitectContradictionCheckerTest {
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store, scoreThreshold = 0.75)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
}
@Test
@@ -92,7 +92,17 @@ class ArchitectContradictionCheckerTest {
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
}
@Test
fun `filters out decisions from another workspace`() = runBlocking {
val store = CannedL3MemoryStore(
listOf(hit("Other workspace decision.", score = 0.95f, turnId = "project:/other-repo")),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
}
@Test
@@ -102,6 +112,6 @@ class ArchitectContradictionCheckerTest {
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store."))
assertNull(checker.check(newSession, stageId, "Use Postgres for the event store.", "/repo"))
}
}
@@ -44,7 +44,7 @@ class ProjectMemoryServiceObservationTest {
@Test
fun `probe success emits WorkspaceStateObservedEvent`(): Unit = runBlocking {
val es = InMemoryEventStore()
val config = ProjectConfig(enabled = true, root = "/repo")
val config = ProjectConfig(enabled = true)
val sessionId = SessionId("session-obs-1")
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
@@ -63,7 +63,7 @@ class ProjectMemoryServiceObservationTest {
@Test
fun `probe null emits no event`(): Unit = runBlocking {
val es = InMemoryEventStore()
val config = ProjectConfig(enabled = true, root = "/repo")
val config = ProjectConfig(enabled = true)
val sessionId = SessionId("session-obs-2")
service(config, es, null).observeAndRecord(sessionId, "/repo")
@@ -76,7 +76,7 @@ class ProjectMemoryServiceObservationTest {
@Test
fun `disabled config skips observation`(): Unit = runBlocking {
val es = InMemoryEventStore()
val config = ProjectConfig(enabled = false, root = "/repo")
val config = ProjectConfig(enabled = false)
val sessionId = SessionId("session-obs-3")
service(config, es, WorkspaceState("git:abc123", "git", "main", false))
@@ -40,9 +40,8 @@ class ProjectMemoryServiceReuseTest {
l3: InMemoryL3MemoryStore,
indexer: CountingIndexer,
probe: WorkspaceStateProbe,
root: String = "/repo",
) = ProjectMemoryService(
config = ProjectConfig(enabled = true, root = root),
config = ProjectConfig(enabled = true),
embedder = ConstantEmbedderReuse(),
l3MemoryStore = l3,
journalRepository = DefaultDecisionJournalRepository(
@@ -136,9 +135,9 @@ class ProjectMemoryServiceReuseTest {
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
service(es, l3, CountingIndexer(repoEntries), probe, root = "/repo")
service(es, l3, CountingIndexer(repoEntries), probe)
.indexAndRecord(SessionId("session-collide-repo"), "/repo")
service(es, l3, CountingIndexer(repo2Entries), probe, root = "/repo2")
service(es, l3, CountingIndexer(repo2Entries), probe)
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
// existsByTurnIdPrefix with the delimiter must not match the other root.
@@ -58,7 +58,7 @@ class ProjectMemoryServiceTest {
fun `decisions persisted in one session are retrieved and seeded in the next`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val config = ProjectConfig(enabled = true, root = "/repo", memoryK = 5)
val config = ProjectConfig(enabled = true, memoryK = 5)
val sessionA = SessionId("A")
store(sessionA, SteeringNoteAddedEvent(sessionA, "use jwt for auth"), eventStore)
@@ -85,7 +85,7 @@ class ProjectMemoryServiceTest {
fun `disabled project memory is a no-op`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val l3 = InMemoryL3MemoryStore()
val config = ProjectConfig(enabled = false, root = "/repo")
val config = ProjectConfig(enabled = false)
val sessionA = SessionId("A")
store(sessionA, SteeringNoteAddedEvent(sessionA, "secret"), eventStore)
+1
View File
@@ -16,6 +16,7 @@ CORREX kernel team.
- `ArtifactState` rebuilt from events via `DefaultArtifactReducer` (in `core:events` `ArtifactEvents.kt`) + `ArtifactProjector`.
- `TypedArtifactSlot<T>` — typed accessor for well-known artifact slots.
- `ArtifactSerializationModule` — registers artifact polymorphic types; must be included in serialization setup.
- `KindContractTable.toolchainFor` maps checked-in Node and JVM kinds to the execution-gate command namespace.
- Hard Invariant #1: artifact state is always rebuilt from events. `ArtifactRepository` wraps `EventReplayer<ArtifactState>`.
## Work Guidance
@@ -18,6 +18,12 @@ package com.correx.core.artifacts.kind
*/
object KindContractTable {
/** Toolchain selected by the build gate for a produced source or manifest kind. */
enum class Toolchain(val profileKey: String) {
NODE("node"),
JVM("jvm"),
}
/** A single assertion template — a kind's requirement before it is bound to a concrete path. */
private data class Template(
val id: String,
@@ -133,6 +139,9 @@ object KindContractTable {
private val CHECKED_IN: Map<String, List<Template>> = TS_REACT + KOTLIN + GENERIC
private val TOOLCHAINS = TS_REACT.keys.associateWith { Toolchain.NODE } +
KOTLIN.keys.associateWith { Toolchain.JVM }
/** Project-supplied additive overrides, keyed by kind id. Set at wiring time; empty by default. */
@Volatile
var projectOverrides: Map<String, List<ContractAssertion>> = emptyMap()
@@ -157,5 +166,8 @@ object KindContractTable {
return (base + overrides).filterNot { it.id == "file_nonempty" && isDotfile(path) }
}
/** The build toolchain implied by a checked-in kind, or null for stack-neutral kinds. */
fun toolchainFor(kindId: String): Toolchain? = TOOLCHAINS[kindId]
private fun isDotfile(path: String): Boolean = path.substringAfterLast('/').startsWith(".")
}
@@ -101,4 +101,13 @@ class KindContractTableTest {
assertTrue("contains" in ids, "override assertion must be appended")
assertTrue("file_nonempty" in ids, "checked-in assertions remain")
}
@Test
fun `toolchain follows checked in source and manifest kinds`() {
assertEquals(KindContractTable.Toolchain.NODE, KindContractTable.toolchainFor("react_entry"))
assertEquals(KindContractTable.Toolchain.NODE, KindContractTable.toolchainFor("package_json"))
assertEquals(KindContractTable.Toolchain.JVM, KindContractTable.toolchainFor("kotlin_service"))
assertEquals(KindContractTable.Toolchain.JVM, KindContractTable.toolchainFor("gradle_module"))
assertEquals(null, KindContractTable.toolchainFor("docs"))
}
}
+1
View File
@@ -16,6 +16,7 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi
- `EditableConfig` — partial config for live patch operations (used by the TUI config editor).
- `OperatorProfile` — operator-level persona and behavior settings.
- `ProjectProfile` / `ProjectProfileLoader` / `ProjectProfileWriter` — project-scoped profile; loaded at session bind time.
- Project-profile commands support flat aliases plus toolchain-specific TOML tables such as `[commands.node]`; nested entries bind into replay-safe dotted keys (for example `node.build`).
- `AgentInstructions` / `AgentInstructionsLoader` — per-role prompt fragments loaded from config.
## Work Guidance
@@ -679,7 +679,6 @@ object ConfigLoader {
val projectSection = sections["project"] ?: emptyMap()
val project = ProjectConfig(
enabled = asBoolean(projectSection["enabled"], false),
root = asString(projectSection["root"], ""),
memoryK = asInt(projectSection["memory_k"], DEFAULT_PROJECT_MEMORY_K),
maxDepth = asInt(projectSection["max_depth"], DEFAULT_PROJECT_MAX_DEPTH),
ignoreGlobs = asStringList(projectSection["ignore_globs"]).ifEmpty { ProjectConfig.DEFAULT_IGNORES },
@@ -146,12 +146,11 @@ data class PersonalizationConfig(
/**
* Project-scoped, cross-session memory. When [enabled], the decision journal is distilled
* to durable per-repo memory at session end and retrieved (top-[memoryK]) at session start.
* [root] is the repo root key; empty means the current working directory.
* The repository key is always the session's recorded bound workspace, never configuration.
*/
@Serializable
data class ProjectConfig(
val enabled: Boolean = false,
val root: String = "",
val memoryK: Int = 5,
val maxDepth: Int = 4,
val ignoreGlobs: List<String> = DEFAULT_IGNORES,
@@ -122,7 +122,6 @@ object CorrexConfigWriter {
b.section("project")
b.kv("enabled", cfg.project.enabled)
b.kv("root", str(cfg.project.root))
b.kv("memory_k", cfg.project.memoryK)
b.kv("max_depth", cfg.project.maxDepth)
b.kv("inject_top_k", cfg.project.injectTopK)
@@ -23,7 +23,13 @@ object ProjectProfileLoader {
return ProjectProfile(
about = SimpleToml.asString(rootKeys["about"], ""),
conventions = SimpleToml.asStringList(rootKeys["conventions"]),
commands = commandsSection.mapValues { (_, v) -> v.toString() },
commands = commandsSection.mapValues { (_, v) -> v.toString() } +
sections.filterKeys { it.startsWith("commands.") }
.flatMap { (section, values) ->
val toolchain = section.removePrefix("commands.")
values.map { (alias, value) -> "$toolchain.$alias" to value.toString() }
}
.toMap(),
)
}
}
@@ -26,13 +26,23 @@ object ProjectProfileWriter {
b.append("conventions = ").append(list(profile.conventions)).append('\n')
}
if (profile.commands.isNotEmpty()) {
val flatCommands = profile.commands.filterKeys { '.' !in it }
val scopedCommands = profile.commands.filterKeys { '.' in it }
.entries.groupBy({ it.key.substringBefore('.') }, { it.key.substringAfter('.') to it.value })
if (flatCommands.isNotEmpty()) {
if (b.isNotEmpty()) b.append('\n')
b.append("[commands]\n")
profile.commands.forEach { (key, value) ->
flatCommands.forEach { (key, value) ->
b.append(key).append(" = ").append(str(value)).append('\n')
}
}
scopedCommands.forEach { (toolchain, commands) ->
if (b.isNotEmpty()) b.append('\n')
b.append("[commands.").append(toolchain).append("]\n")
commands.forEach { (alias, value) ->
b.append(alias).append(" = ").append(str(value)).append('\n')
}
}
return b.toString()
}
@@ -189,7 +189,6 @@ class ConfigLoaderTest {
val toml = """
[project]
enabled = true
root = "/home/me/repo"
memory_k = 8
""".trimIndent()
@@ -199,7 +198,6 @@ class ConfigLoaderTest {
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(true, result.project.enabled)
assertEquals("/home/me/repo", result.project.root)
assertEquals(8, result.project.memoryK)
}
@@ -216,7 +214,6 @@ class ConfigLoaderTest {
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(false, result.project.enabled)
assertEquals("", result.project.root)
assertEquals(5, result.project.memoryK)
}
@@ -36,7 +36,7 @@ class CorrexConfigWriterTest {
narration = NarrationSettings(temperature = 0.1, topP = 0.95, maxTokens = 256, maxPerRun = 3),
),
personalization = PersonalizationConfig(enabled = true, learn = true),
project = ProjectConfig(enabled = true, root = "/repo", memoryK = 8, maxDepth = 6, injectTopK = 40),
project = ProjectConfig(enabled = true, memoryK = 8, maxDepth = 6, injectTopK = 40),
git = GitConfig(enabled = true, remote = "gitea", baseBranch = "develop", author = "Correx <bot@example.test>"),
modelsSettings = ModelsSettings(defaultModel = "m1", host = "0.0.0.0", port = 10001),
orchestration = OrchestrationKnobs(stageTimeoutMs = 90_000, journalCompactionTokenThreshold = 12_000),
@@ -62,6 +62,31 @@ class ProjectProfileLoaderTest {
assertEquals(mapOf("test" to "./gradlew check"), p.commands)
}
@Test
fun `toolchain command tables are represented as dotted command keys`() {
val root = tempRoot()
Files.writeString(
Paths.get(root, ".correx", "project.toml"),
"""
[commands]
build = "./gradlew assemble"
[commands.node]
build = "npm --prefix frontend run build"
test = "npm --prefix frontend test"
""".trimIndent(),
)
assertEquals(
mapOf(
"build" to "./gradlew assemble",
"node.build" to "npm --prefix frontend run build",
"node.test" to "npm --prefix frontend test",
),
ProjectProfileLoader.load(root).commands,
)
}
@Test
fun `malformed file returns default without throwing`() {
val root = tempRoot()
@@ -24,6 +24,7 @@ class ProjectProfileWriterTest {
commands = mapOf(
"build" to "./gradlew build",
"test" to "./gradlew check",
"node.build" to "npm --prefix frontend run build",
),
)
@@ -36,6 +37,16 @@ class ProjectProfileWriterTest {
assertEquals(profile, loaded)
}
@Test
fun `writer serializes scoped commands as TOML subtables`() {
val serialized = ProjectProfileWriter.serialize(
ProjectProfile(commands = mapOf("node.build" to "npm run build")),
)
assertTrue(serialized.contains("[commands.node]"))
assertTrue(serialized.contains("build = \"npm run build\""))
}
@Test
fun `an empty profile serializes to an empty string and skips empty sections`() {
val serialized = ProjectProfileWriter.serialize(ProjectProfile())
+1
View File
@@ -20,6 +20,7 @@ CORREX kernel team. This is the integration point for all other `core/` modules.
- `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events.
- `LspDiagnosticsRunner` — injected pull-diagnostics seam; diagnostics are filtered to stage-written files, recorded, and enforced before build/review.
- Execution gates infer the written stage's Node/JVM toolchain from its recorded file kinds and prefer the matching bound-profile command namespace, falling back to flat aliases.
- Review→rework loops use the configured three-cycle default, then route accumulated notes to recovery once and fail if the fixed DoD still cannot be approved.
- `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions.
- Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow.
@@ -0,0 +1,20 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.KindContractTable
import com.correx.core.artifacts.kind.KindInference
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
/**
* Recovers the produced kind from this stage's recorded file manifest, then maps it to the
* toolchain-specific build command. File writes are event facts, so this remains replay-safe.
*/
internal fun SessionOrchestrator.stageProducedToolchain(
sessionId: SessionId,
stageId: StageId,
): KindContractTable.Toolchain? = toolchainForPaths(stageWrittenPaths(sessionId, stageId))
internal fun toolchainForPaths(paths: List<String>): KindContractTable.Toolchain? =
paths.asReversed().firstNotNullOfOrNull { path ->
KindInference.kindFor(path)?.let(KindContractTable::toolchainFor)
}
@@ -172,12 +172,14 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
val command = profileCommands[alias]
val toolchain = stageProducedToolchain(sessionId, stageId)?.profileKey
val command = expectation.commandFor(profileCommands, toolchain)
if (command.isNullOrBlank()) {
log.warn(
"[Orchestrator] stage {} needs a {} build gate but project profile has no '{}' " +
"[Orchestrator] stage {} needs a {} build gate but project profile has no '{}'{} " +
"command — skipping execution gate",
stageId.value, expectation, alias,
toolchain?.let { " for toolchain '$it'" }.orEmpty(),
)
return StageExecutionResult.Success(emptyList())
}
@@ -1,5 +1,6 @@
package com.correx.core.kernel.orchestration
import com.correx.core.artifacts.kind.KindContractTable
import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
@@ -17,6 +18,12 @@ import org.junit.jupiter.api.Test
class BuildPrerequisiteDecisionTest {
@Test
fun `build gate toolchain follows the stage produced kind`() {
assertEquals(KindContractTable.Toolchain.NODE, toolchainForPaths(listOf("frontend/package.json")))
assertEquals(KindContractTable.Toolchain.JVM, toolchainForPaths(listOf("core/kernel/FooService.kt")))
}
private val reason =
"stage impl repeatedly referenced missing build prerequisite 'frontend/package.json' " +
"(3 blocked attempts). Create or repair the project setup before continuing."
+1
View File
@@ -28,6 +28,7 @@ CORREX kernel team.
- Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip).
- `WorkflowGraph` is built from config/TOML at session start; it is immutable during a session.
- `StageConfig.autoBuildGate` is a compiler-set request for a runtime build gate; it is used on write-declaring freestyle stages only when no explicit build expectation exists.
- `BuildExpectation` resolves toolchain-scoped command aliases (`node.build`, `jvm.build`) before legacy flat aliases, keeping profile snapshots replayable.
- Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime.
## Verification
@@ -7,7 +7,10 @@ package com.correx.core.transitions.graph
* stays stack-specific and operator-owned while the *scope* is a fixed vocabulary the planner can't
* invent.
*
* [commandAlias] names the `[project.commands]` alias to run for this scope (null = no gate):
* [commandAlias] names the `[commands]` alias to run for this scope (null = no gate). A
* toolchain-specific command is stored as `<toolchain>.<alias>` in the replay-bound profile map;
* this corresponds to TOML such as `[commands.node]`. The resolver prefers that scoped command and
* falls back to the flat alias for existing profiles.
* NONE → nothing (early stages where the project is not yet runnable), MODULE → typecheck,
* PROJECT → build, TESTS → test. A stage whose profile lacks the alias skips the gate with a warning
* rather than failing — the vocabulary degrades safely when the operator hasn't configured commands.
@@ -19,6 +22,16 @@ enum class BuildExpectation(val commandAlias: String?) {
TESTS("test"),
;
/**
* Resolves this gate's command from a profile snapshot. The profile remains a flat map in the
* event vocabulary, so nested TOML command tables are represented by dotted keys such as
* `node.build`. This makes the toolchain choice replayable without changing session events.
*/
fun commandFor(profileCommands: Map<String, String>, toolchain: String?): String? {
val alias = commandAlias ?: return null
return toolchain?.let { profileCommands["$it.$alias"] } ?: profileCommands[alias]
}
companion object {
/** Parses a plan's `build_expectation` string; null for an unrecognized value so the compiler rejects it. */
fun fromPlan(raw: String?): BuildExpectation? = when (raw?.trim()?.lowercase()) {
@@ -0,0 +1,32 @@
package com.correx.core.transitions.graph
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class BuildExpectationTest {
@Test
fun `toolchain command overrides flat command for matching produced kind`() {
val commands = mapOf(
"build" to "./gradlew assemble",
"node.build" to "npm --prefix frontend run build",
)
assertEquals("npm --prefix frontend run build", BuildExpectation.PROJECT.commandFor(commands, "node"))
assertEquals("./gradlew assemble", BuildExpectation.PROJECT.commandFor(commands, "jvm"))
}
@Test
fun `flat command remains compatible when a toolchain has no override`() {
assertEquals(
"./gradlew check",
BuildExpectation.TESTS.commandFor(mapOf("test" to "./gradlew check"), "jvm"),
)
}
@Test
fun `none has no command`() {
assertNull(BuildExpectation.NONE.commandFor(mapOf("build" to "ignored"), "node"))
}
}
@@ -127,7 +127,7 @@ class RepoMapReuseIntegrationTest {
embedder: Embedder,
probe: FakeWorkspaceStateProbe,
) = ProjectMemoryService(
config = ProjectConfig(enabled = true, root = repoRoot),
config = ProjectConfig(enabled = true),
embedder = embedder,
l3MemoryStore = l3,
journalRepository = DefaultDecisionJournalRepository(