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
@@ -31,3 +31,19 @@ data class IdeaDiscardedEvent(
val ideaId: String,
val sessionId: SessionId,
) : EventPayload
/**
* The operator promoted an idea from the cross-session board into the curated per-repo profile
* (`<workspaceRoot>/.correx/project.toml`, as a `conventions` entry). Like [IdeaDiscardedEvent] this
* is a tombstone — the original [IdeaCapturedEvent] stays in the log (and replays), but the idea-board
* projection filters out any promoted [ideaId]. [sessionId] is the capturing session (so all of one
* idea's events stay co-located); [text] records the convention text written to the profile.
*/
@Serializable
@SerialName("IdeaPromoted")
data class IdeaPromotedEvent(
val ideaId: String,
val sessionId: SessionId,
val text: String,
val timestampMs: Long,
) : EventPayload
@@ -29,6 +29,7 @@ import com.correx.core.events.events.HealthDegradedEvent
import com.correx.core.events.events.HealthRestoredEvent
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.JournalCompactedEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.L3MemoryRetrievedEvent
@@ -137,6 +138,7 @@ val eventModule = SerializersModule {
subclass(WorkflowProposedEvent::class)
subclass(IdeaCapturedEvent::class)
subclass(IdeaDiscardedEvent::class)
subclass(IdeaPromotedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class)
@@ -6,6 +6,7 @@ import com.correx.core.approvals.Tier
import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.IdeaPromotedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.RiskAssessedEvent
import com.correx.core.events.events.SteeringNoteAddedEvent
@@ -81,6 +82,12 @@ class EventSerializationHardeningTest {
terminalStageId = stageId,
totalStages = 1,
),
"IdeaPromoted" to IdeaPromotedEvent(
ideaId = "idea-1",
sessionId = sessionId,
text = "cache the repo map across sessions",
timestampMs = 1_700_000_000_000,
),
)
@Test