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
@@ -2,6 +2,7 @@ package com.correx.core.router
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId
import com.correx.core.router.model.Idea
@@ -10,28 +11,36 @@ import com.correx.core.router.model.Idea
* Rebuilds the operator's idea board from the event log. Cross-session by design: it folds over
* [EventStore.allEvents] rather than a single session's replay, so an idea captured in one session
* shows on the board (and feeds the router) in every session. A captured idea is dropped once a
* matching [IdeaDiscardedEvent] tombstones it (invariant #1 — the capture stays in the log).
* matching [IdeaDiscardedEvent] (or [IdeaPromotedEvent]) tombstones it (invariant #1 — the capture
* stays in the log).
*/
class IdeaReader(private val eventStore: EventStore) {
/** Active ideas (captured, not later discarded) across all sessions, newest first. */
/** Active ideas (captured, not later discarded or promoted) across all sessions, newest first. */
fun activeIdeas(): List<Idea> {
val discarded = mutableSetOf<String>()
val tombstoned = mutableSetOf<String>()
val captured = mutableListOf<Idea>()
eventStore.allEvents().forEach { stored ->
when (val payload = stored.payload) {
is IdeaDiscardedEvent -> discarded += payload.ideaId
is IdeaDiscardedEvent -> tombstoned += payload.ideaId
is IdeaPromotedEvent -> tombstoned += payload.ideaId
is IdeaCapturedEvent -> captured += Idea(payload.ideaId, payload.text, payload.timestampMs)
else -> Unit
}
}
return captured.filterNot { it.id in discarded }.sortedByDescending { it.capturedAtMs }
return captured.filterNot { it.id in tombstoned }.sortedByDescending { it.capturedAtMs }
}
/** The session that captured [ideaId], so a discard tombstone lands alongside its capture. */
/** The session that captured [ideaId], so a tombstone lands alongside its capture. */
fun sessionOf(ideaId: String): SessionId? =
capturedOf(ideaId)?.sessionId
/** The captured text of [ideaId] (so a promotion can write it into the project profile), or null. */
fun textOf(ideaId: String): String? =
capturedOf(ideaId)?.text
private fun capturedOf(ideaId: String): IdeaCapturedEvent? =
eventStore.allEvents()
.mapNotNull { it.payload as? IdeaCapturedEvent }
.firstOrNull { it.ideaId == ideaId }
?.sessionId
}
@@ -0,0 +1,100 @@
package com.correx.core.router
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaCapturedEvent
import com.correx.core.events.events.IdeaDiscardedEvent
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.datetime.Instant
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class IdeaReaderTest {
private val session = SessionId("s-1")
@Test
fun `a promoted idea is filtered out of the board like a discard`() {
val store = fakeStore(
IdeaCapturedEvent("keep", session, "cache the repo map", timestampMs = 1),
IdeaCapturedEvent("promoted", session, "no bare try-catch", timestampMs = 2),
IdeaCapturedEvent("discarded", session, "old note", timestampMs = 3),
IdeaPromotedEvent("promoted", session, "no bare try-catch", timestampMs = 99),
IdeaDiscardedEvent("discarded", session),
)
val active = IdeaReader(store).activeIdeas()
assertEquals(listOf("keep"), active.map { it.id })
assertFalse(active.any { it.id == "promoted" }, "promoted idea must leave the board")
assertFalse(active.any { it.id == "discarded" }, "discarded idea must leave the board")
}
@Test
fun `textOf returns the captured text and null for an unknown idea`() {
val store = fakeStore(
IdeaCapturedEvent("i1", session, "events are the only source of truth", timestampMs = 1),
)
val reader = IdeaReader(store)
assertEquals("events are the only source of truth", reader.textOf("i1"))
assertNull(reader.textOf("missing"))
}
@Test
fun `textOf still resolves the text after the idea is promoted`() {
val store = fakeStore(
IdeaCapturedEvent("i1", session, "prefer data classes", timestampMs = 1),
IdeaPromotedEvent("i1", session, "prefer data classes", timestampMs = 5),
)
val reader = IdeaReader(store)
// The capture stays in the log (invariant #1), so the text remains readable.
assertEquals("prefer data classes", reader.textOf("i1"))
assertTrue(reader.activeIdeas().isEmpty())
}
private fun fakeStore(vararg payloads: EventPayload): EventStore = FakeEventStore(payloads.toList())
/** Minimal read-only [EventStore]: only [allEvents] is exercised by [IdeaReader]. */
private class FakeEventStore(payloads: List<EventPayload>) : EventStore {
private val stored: List<StoredEvent> = payloads.mapIndexed { index, payload ->
StoredEvent(
metadata = EventMetadata(
eventId = EventId("e-$index"),
sessionId = SessionId("s-1"),
timestamp = Instant.fromEpochMilliseconds(index.toLong()),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
sequence = index.toLong(),
sessionSequence = index.toLong(),
payload = payload,
)
}
override fun allEvents(): Sequence<StoredEvent> = stored.asSequence()
override suspend fun append(event: NewEvent): StoredEvent = throw NotImplementedError()
override suspend fun appendAll(events: List<NewEvent>): List<StoredEvent> = throw NotImplementedError()
override fun read(sessionId: SessionId): List<StoredEvent> = throw NotImplementedError()
override fun readFrom(sessionId: SessionId, fromSequence: Long): List<StoredEvent> =
throw NotImplementedError()
override fun lastSequence(sessionId: SessionId): Long? = throw NotImplementedError()
override fun subscribe(sessionId: SessionId): Flow<StoredEvent> = emptyFlow()
override fun subscribeAll(): Flow<StoredEvent> = emptyFlow()
override suspend fun lastGlobalSequence(): Long = throw NotImplementedError()
override fun allSessionIds(): Set<SessionId> = throw NotImplementedError()
}
}