feat(router): idea-board -> project-profile promotion (E)

Promote an auto-captured idea from the cross-session board into the curated per-repo
profile at <workspaceRoot>/.correx/project.toml as a `conventions` entry. New
IdeaPromotedEvent tombstones the idea off the board (IdeaReader treats it like a
discard) and records the promotion as a fact; the server PromoteIdea handler loads
the profile, appends the idea text (deduped via ProjectProfile.withConvention), and
writes it back. Adds ProjectProfileWriter (escape-aware TOML serialize/write) and
makes SimpleToml's reader unescape \\ and \" symmetrically with the writers
(fixes a pre-existing read/write asymmetry surfaced by the round-trip test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 07:30:43 +00:00
parent eeb6830387
commit f107ff554c
11 changed files with 396 additions and 10 deletions
@@ -56,6 +56,14 @@ sealed class ClientMessage {
@Serializable
data class DiscardIdea(val ideaId: String) : ClientMessage()
/**
* Operator request to promote an idea into the bound session's curated project profile (added as
* a `conventions` entry in `<workspaceRoot>/.correx/project.toml`). Tombstones the idea like a
* discard; reply is a fresh IdeaList.
*/
@Serializable
data class PromoteIdea(val ideaId: String) : ClientMessage()
/** Operator request for the current editable config (replied to with a ConfigSnapshot). */
@Serializable
data object GetConfig : ClientMessage()
@@ -19,6 +19,7 @@ import com.correx.core.events.events.ApprovalGrantCreatedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.InitialIntentEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
@@ -30,6 +31,9 @@ import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.inference.ProviderHealth
import com.correx.core.kernel.orchestration.WorkspaceContext
import com.correx.core.config.ProjectProfileLoader
import com.correx.core.config.ProjectProfileWriter
import com.correx.core.config.withConvention
import com.correx.core.router.IdeaReader
import com.correx.core.utils.TypeId
import com.correx.infrastructure.inference.commons.ResourceProbe
@@ -233,6 +237,7 @@ class GlobalStreamHandler(private val module: ServerModule) {
is ClientMessage.ListArtifacts -> sendFrame(queries.listArtifacts(msg.sessionId))
is ClientMessage.ListIdeas -> sendFrame(queries.listIdeas())
is ClientMessage.DiscardIdea -> handleDiscardIdea(msg, sendFrame)
is ClientMessage.PromoteIdea -> handlePromoteIdea(msg, sendFrame)
is ClientMessage.GetSessionStats -> sendFrame(queries.sessionStats(msg.sessionId))
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
@@ -336,6 +341,57 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(queries.listIdeas())
}
// Promote an idea into the bound session's curated project profile (a `conventions` entry in
// <workspaceRoot>/.correx/project.toml), then tombstone it like a discard so it leaves the board.
// No-ops (just refresh the board) when the idea is missing or its capturing session has no bound
// workspace — promotion needs a workspace to write the profile into.
private suspend fun handlePromoteIdea(
msg: ClientMessage.PromoteIdea,
sendFrame: suspend (ServerMessage) -> Unit,
) {
runCatching {
val reader = IdeaReader(module.eventStore)
val sessionId = reader.sessionOf(msg.ideaId) ?: return@runCatching
val text = reader.textOf(msg.ideaId) ?: return@runCatching
val workspaceRoot = workspaceRootOf(sessionId) ?: return@runCatching
withContext(Dispatchers.IO) {
val updated = ProjectProfileLoader.load(workspaceRoot).withConvention(text)
ProjectProfileWriter.write(workspaceRoot, updated)
}
module.eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = IdeaPromotedEvent(
ideaId = msg.ideaId,
sessionId = sessionId,
text = text,
timestampMs = System.currentTimeMillis(),
),
),
)
}.onFailure {
log.error("promoteIdea failed for ideaId={}: {}", msg.ideaId, it.message, it)
}
sendFrame(queries.listIdeas())
}
// The workspace a session was bound to, from its latest SessionWorkspaceBoundEvent (invariant #9),
// or null if the session never bound one.
private fun workspaceRootOf(sessionId: SessionId): String? =
module.eventStore.read(sessionId)
.mapNotNull { it.payload as? SessionWorkspaceBoundEvent }
.lastOrNull()
?.workspaceRoot
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
private fun encodeError(message: String): Frame.Text =
@@ -267,6 +267,14 @@ class ServerMessageSerializationTest {
assertEquals("i1", (decoded as ClientMessage.DiscardIdea).ideaId)
}
@Test
fun `PromoteIdea decodes from the client wire format`() {
val wire = """{"type":"com.correx.apps.server.protocol.ClientMessage.PromoteIdea","ideaId":"i1"}"""
val decoded = ProtocolSerializer.decodeClientMessage(wire)
assert(decoded is ClientMessage.PromoteIdea) { "expected PromoteIdea, got $decoded" }
assertEquals("i1", (decoded as ClientMessage.PromoteIdea).ideaId)
}
@Test
fun `ClarificationResponse decodes from the client wire format`() {
val wire = """