merge: integrate feat/backlog-burndown into master
master had advanced 16 commits past feat/backlog-burndown's base, and the two branches independently built four of the same features. Resolved 26 conflicts. Overlap features — kept master's implementation (more complete / production-wired / more robust), dropped the feature branch's parallel constellation: - llama-server health probe: kept master's event-store-backed tps probe; dropped the branch's LlamaLivenessClient (liveness-only, throughput unwired). - event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe. - brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording); dropped the branch's exact-set-diff BriefEchoComparator/Extractor. - static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner, wired); dropped the branch's structured-finding static_check stage (no-op seam). Its structured-findings model is filed as a follow-up in BACKLOG. Feature-branch net-new work brought in and kept (master had none): - native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer, dependency graph + gates, decompose, REST/CLI, TUI task board) - critique-outcome producer (role-rel §6 — master had deferred it) - stage-level plan checkpointing (C-A2, folded into runPostStageGates) - CLAUDE.md/AGENTS.md L0 standing context - cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser) Verified: full Gradle compile (all modules + tests) green; tests pass for core:events, core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String> = emptyList(),
|
||||
val content: String = "",
|
||||
) {
|
||||
fun isEmpty(): Boolean = sources.isEmpty() && content.isBlank()
|
||||
}
|
||||
@@ -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<String>()
|
||||
val sources = mutableListOf<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,11 @@ internal object SimpleToml {
|
||||
var depth = 0
|
||||
var inQuotes = false
|
||||
var quoteChar = ' '
|
||||
var escaped = false
|
||||
for (ch in value) {
|
||||
when {
|
||||
escaped -> escaped = false
|
||||
ch == '\\' && inQuotes && quoteChar == '"' -> escaped = true
|
||||
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch }
|
||||
ch == quoteChar && inQuotes -> inQuotes = false
|
||||
ch == '[' && !inQuotes -> depth++
|
||||
@@ -92,8 +95,13 @@ internal object SimpleToml {
|
||||
var current = StringBuilder()
|
||||
var inQuotes = false
|
||||
var quoteChar = ' '
|
||||
var escaped = false
|
||||
for (ch in content) {
|
||||
when {
|
||||
// Keep escape sequences (e.g. \") intact so they reach stripQuotes verbatim; an
|
||||
// escaped quote must not be treated as the string's closing delimiter.
|
||||
escaped -> { current.append(ch); escaped = false }
|
||||
ch == '\\' && inQuotes && quoteChar == '"' -> { current.append(ch); escaped = true }
|
||||
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch; current.append(ch) }
|
||||
ch == quoteChar && inQuotes -> { inQuotes = false; current.append(ch) }
|
||||
ch == ',' && !inQuotes -> { result.add(stripQuotes(current.toString().trim())); current = StringBuilder() }
|
||||
@@ -104,10 +112,39 @@ internal object SimpleToml {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the surrounding quotes from [value] and, for double-quoted strings, unescapes the
|
||||
* `\\` and `\"` sequences emitted by the writers (mirrors [ProjectProfileWriter]/[CorrexConfigWriter]).
|
||||
*/
|
||||
private fun stripQuotes(value: String): String {
|
||||
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"")
|
||||
val isSingleQuoted = value.startsWith("'") && value.endsWith("'")
|
||||
return if (isDoubleQuoted || isSingleQuoted) value.substring(1, value.length - 1) else value
|
||||
val isDoubleQuoted = value.startsWith("\"") && value.endsWith("\"") && value.length >= 2
|
||||
val isSingleQuoted = value.startsWith("'") && value.endsWith("'") && value.length >= 2
|
||||
return when {
|
||||
isDoubleQuoted -> unescapeDoubleQuoted(value.substring(1, value.length - 1))
|
||||
isSingleQuoted -> value.substring(1, value.length - 1)
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
/** Reverses the writer's escaping: `\\` -> `\`, `\"` -> `"`; a trailing lone `\` is kept as-is. */
|
||||
private fun unescapeDoubleQuoted(value: String): String {
|
||||
if ('\\' !in value) return value
|
||||
val sb = StringBuilder(value.length)
|
||||
var i = 0
|
||||
while (i < value.length) {
|
||||
val ch = value[i]
|
||||
if (ch == '\\' && i + 1 < value.length) {
|
||||
val next = value[i + 1]
|
||||
if (next == '\\' || next == '"') {
|
||||
sb.append(next)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.append(ch)
|
||||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.correx.core.config
|
||||
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* Serializes a [ProjectProfile] back to TOML text that [ProjectProfileLoader] reads identically:
|
||||
* the invariant is `load(write(profile)) == profile` for every field the loader understands
|
||||
* (about, conventions, commands).
|
||||
*
|
||||
* Like [CorrexConfigWriter] this **regenerates** the file from the profile object — it does not
|
||||
* preserve hand-written comments. Empty sections are skipped: a blank `about` and an empty
|
||||
* `conventions`/`commands` produce no output for that part (the loader reconstructs the defaults
|
||||
* from their absence), so a [ProjectProfile.isEmpty] profile serializes to an empty string.
|
||||
*/
|
||||
object ProjectProfileWriter {
|
||||
|
||||
/** Renders [profile] as TOML. String values escape `\` and `"`; empty sections are skipped. */
|
||||
fun serialize(profile: ProjectProfile): String {
|
||||
val b = StringBuilder()
|
||||
|
||||
if (profile.about.isNotBlank()) {
|
||||
b.append("about = ").append(str(profile.about)).append('\n')
|
||||
}
|
||||
|
||||
if (profile.conventions.isNotEmpty()) {
|
||||
b.append("conventions = ").append(list(profile.conventions)).append('\n')
|
||||
}
|
||||
|
||||
if (profile.commands.isNotEmpty()) {
|
||||
if (b.isNotEmpty()) b.append('\n')
|
||||
b.append("[commands]\n")
|
||||
profile.commands.forEach { (key, value) ->
|
||||
b.append(key).append(" = ").append(str(value)).append('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return b.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes [profile] to `<workspaceRoot>/.correx/project.toml`, creating the `.correx/` directory
|
||||
* if it is missing. Overwrites any existing file (the writer owns the file once a save happens).
|
||||
*/
|
||||
fun write(workspaceRoot: String, profile: ProjectProfile) {
|
||||
val path = ProjectProfileLoader.profilePath(workspaceRoot)
|
||||
Files.createDirectories(path.parent)
|
||||
Files.writeString(path, serialize(profile))
|
||||
}
|
||||
|
||||
private fun str(v: String): String = "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
|
||||
private fun list(v: List<String>): String = "[" + v.joinToString(", ") { str(it) } + "]"
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends [text] to this profile's conventions, de-duplicated by exact string match. A blank
|
||||
* [text] is ignored (returns the profile unchanged). Used when promoting an idea-board entry into
|
||||
* the curated profile so repeated promotions of the same text don't pile up duplicate conventions.
|
||||
*/
|
||||
fun ProjectProfile.withConvention(text: String): ProjectProfile {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isBlank() || trimmed in conventions) return this
|
||||
return copy(conventions = conventions + trimmed)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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.assertSame
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class ProjectProfileWriterTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
@Test
|
||||
fun `write then load round-trips about conventions and commands including embedded quotes`() {
|
||||
val profile = ProjectProfile(
|
||||
about = "Event-sourced kernel \"the\" orchestrator, Kotlin/JVM 21",
|
||||
conventions = listOf(
|
||||
"no bare try-catch",
|
||||
"prefer the \"narrow\" interface", // a convention with a double-quote
|
||||
),
|
||||
commands = mapOf(
|
||||
"build" to "./gradlew build",
|
||||
"test" to "./gradlew check",
|
||||
),
|
||||
)
|
||||
|
||||
ProjectProfileWriter.write(tempDir.toString(), profile)
|
||||
val loaded = ProjectProfileLoader.load(tempDir.toString())
|
||||
|
||||
assertEquals(profile.about, loaded.about)
|
||||
assertEquals(profile.conventions, loaded.conventions)
|
||||
assertEquals(profile.commands, loaded.commands)
|
||||
assertEquals(profile, loaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty profile serializes to an empty string and skips empty sections`() {
|
||||
val serialized = ProjectProfileWriter.serialize(ProjectProfile())
|
||||
assertEquals("", serialized)
|
||||
assertFalse(serialized.contains("[commands]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank about is omitted but conventions and commands are still written`() {
|
||||
val serialized = ProjectProfileWriter.serialize(
|
||||
ProjectProfile(
|
||||
about = " ",
|
||||
conventions = listOf("c1"),
|
||||
commands = mapOf("run" to "x"),
|
||||
),
|
||||
)
|
||||
assertFalse(serialized.contains("about ="), "blank about must be skipped")
|
||||
assertTrue(serialized.contains("conventions = [\"c1\"]"))
|
||||
assertTrue(serialized.contains("[commands]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withConvention appends a new convention`() {
|
||||
val updated = ProjectProfile(conventions = listOf("a")).withConvention("b")
|
||||
assertEquals(listOf("a", "b"), updated.conventions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withConvention de-dupes an exact existing convention`() {
|
||||
val profile = ProjectProfile(conventions = listOf("no bare try-catch"))
|
||||
val updated = profile.withConvention("no bare try-catch")
|
||||
assertSame(profile, updated, "an exact duplicate must return the same profile unchanged")
|
||||
assertEquals(listOf("no bare try-catch"), updated.conventions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withConvention ignores blank text`() {
|
||||
val profile = ProjectProfile(conventions = listOf("a"))
|
||||
assertSame(profile, profile.withConvention(" "))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user