feat(config): ProjectProfile — per-repo standing context at .correx/project.toml

This commit is contained in:
2026-06-10 21:45:46 +04:00
parent 0de02bb3a7
commit 5eda64949f
4 changed files with 139 additions and 34 deletions
@@ -4,24 +4,14 @@ import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths import java.nio.file.Paths
object ProfileLoader { /**
fun profilePath(homeDir: String = System.getProperty("user.home")): Path = * Minimal hand-rolled TOML parser shared by [ProfileLoader] and [ProjectProfileLoader].
Paths.get(homeDir, ".config", "correx", "profile.toml") * Handles root key-value pairs, `[section]` tables, string arrays, booleans, and numbers.
* Does NOT handle array-of-tables (`[[…]]`) — use [ConfigLoader] for those.
fun load(homeDir: String = System.getProperty("user.home")): OperatorProfile { */
val path = profilePath(homeDir) internal object SimpleToml {
if (!Files.exists(path)) return OperatorProfile() /** Returns (rootKeys, sections) from a simple TOML string. */
fun parse(content: String): Pair<Map<String, Any>, Map<String, Map<String, Any>>> {
return runCatching {
val content = Files.readString(path)
parseProfile(content)
}.getOrElse { e ->
System.err.println("Warning: Failed to parse profile at $path: ${e.message}")
OperatorProfile()
}
}
private fun parseProfile(content: String): OperatorProfile {
val lines = content.trim().split("\n") val lines = content.trim().split("\n")
var currentSection = "" var currentSection = ""
val rootKeys = mutableMapOf<String, Any>() val rootKeys = mutableMapOf<String, Any>()
@@ -50,18 +40,16 @@ object ProfileLoader {
} }
} }
} }
return Pair(rootKeys, sections)
}
val prefsSection = sections["preferences"] ?: emptyMap() fun asString(value: Any?, default: String = ""): String =
val preferences = ProfilePreferences( if (value is String) value else default
approvalMode = asString(prefsSection["approval_mode"], ""),
preferredModels = asStringList(prefsSection["preferred_models"]),
conventions = asStringList(prefsSection["conventions"]),
)
return OperatorProfile( fun asStringList(value: Any?): List<String> = when (value) {
about = asString(rootKeys["about"], ""), is List<*> -> value.filterIsInstance<String>()
preferences = preferences, is String -> listOf(value)
) else -> emptyList()
} }
private fun parseSimpleValue(valueStr: String): Any = when { private fun parseSimpleValue(valueStr: String): Any = when {
@@ -96,14 +84,37 @@ object ProfileLoader {
val isSingleQuoted = value.startsWith("'") && value.endsWith("'") val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value
} }
}
private fun asString(value: Any?, default: String = ""): String = object ProfileLoader {
if (value is String) value else default fun profilePath(homeDir: String = System.getProperty("user.home")): Path =
Paths.get(homeDir, ".config", "correx", "profile.toml")
private fun asStringList(value: Any?): List<String> = when (value) { fun load(homeDir: String = System.getProperty("user.home")): OperatorProfile {
is List<*> -> value.filterIsInstance<String>() val path = profilePath(homeDir)
is String -> listOf(value) if (!Files.exists(path)) return OperatorProfile()
else -> emptyList()
return runCatching {
val content = Files.readString(path)
parseProfile(content)
}.getOrElse { e ->
System.err.println("Warning: Failed to parse profile at $path: ${e.message}")
OperatorProfile()
}
}
private fun parseProfile(content: String): OperatorProfile {
val (rootKeys, sections) = SimpleToml.parse(content)
val prefsSection = sections["preferences"] ?: emptyMap()
val preferences = ProfilePreferences(
approvalMode = SimpleToml.asString(prefsSection["approval_mode"], ""),
preferredModels = SimpleToml.asStringList(prefsSection["preferred_models"]),
conventions = SimpleToml.asStringList(prefsSection["conventions"]),
)
return OperatorProfile(
about = SimpleToml.asString(rootKeys["about"], ""),
preferences = preferences,
)
} }
} }
@@ -0,0 +1,18 @@
package com.correx.core.config
import kotlinx.serialization.Serializable
/**
* Curated per-repo standing context, authored by the operator at
* `<workspaceRoot>/.correx/project.toml`. Unlike distilled project memory (reactive,
* derived from decision journals), this is the deliberate "how to work in this repo"
* card: injected as L0 for every stage of every session bound to the workspace.
*/
@Serializable
data class ProjectProfile(
val about: String = "",
val conventions: List<String> = emptyList(),
val commands: Map<String, String> = emptyMap(),
) {
fun isEmpty(): Boolean = about.isBlank() && conventions.isEmpty() && commands.isEmpty()
}
@@ -0,0 +1,29 @@
package com.correx.core.config
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
object ProjectProfileLoader {
fun profilePath(workspaceRoot: String): Path =
Paths.get(workspaceRoot, ".correx", "project.toml")
fun load(workspaceRoot: String): ProjectProfile {
val path = profilePath(workspaceRoot)
if (!Files.exists(path)) return ProjectProfile()
return runCatching { parse(Files.readString(path)) }.getOrElse { e ->
System.err.println("Warning: Failed to parse project profile at $path: ${e.message}")
ProjectProfile()
}
}
private fun parse(content: String): ProjectProfile {
val (rootKeys, sections) = SimpleToml.parse(content)
val commandsSection = sections["commands"] ?: emptyMap()
return ProjectProfile(
about = SimpleToml.asString(rootKeys["about"], ""),
conventions = SimpleToml.asStringList(rootKeys["conventions"]),
commands = commandsSection.mapValues { (_, v) -> v.toString() },
)
}
}
@@ -0,0 +1,47 @@
package com.correx.core.config
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Paths
class ProjectProfileLoaderTest {
private fun tempRoot(): String {
val dir = Files.createTempDirectory("correx-project-profile-test")
Files.createDirectories(dir.resolve(".correx"))
return dir.toString()
}
@Test
fun `missing file returns default ProjectProfile`() {
assertEquals(ProjectProfile(), ProjectProfileLoader.load(tempRoot()))
}
@Test
fun `full file populates about conventions and commands`() {
val root = tempRoot()
Files.writeString(
Paths.get(root, ".correx", "project.toml"),
"""
about = "Event-sourced orchestration kernel, Kotlin/JVM 21"
conventions = ["no bare try-catch", "events are the only source of truth"]
[commands]
build = "./gradlew build"
test = "./gradlew check"
""".trimIndent(),
)
val p = ProjectProfileLoader.load(root)
assertEquals("Event-sourced orchestration kernel, Kotlin/JVM 21", p.about)
assertEquals(listOf("no bare try-catch", "events are the only source of truth"), p.conventions)
assertEquals(mapOf("build" to "./gradlew build", "test" to "./gradlew check"), p.commands)
}
@Test
fun `malformed file returns default without throwing`() {
val root = tempRoot()
Files.writeString(Paths.get(root, ".correx", "project.toml"), "[[[not toml")
assertEquals(ProjectProfile(), ProjectProfileLoader.load(root))
}
}